trieur.

The throw

Experimental. The flick plugin turns a release into a throw: the card keeps the speed it had, travels a little further on its own, and is filed where that carries it. See it in Throw, don’t drop.

A drop asks you to be accurate about a coordinate — let go over the right cell. A throw asks you to be accurate about a direction, which the hand is far better at. It is aimed at the two cases where dropping is a coin toss: zones far from the card, where the drag has to cross the whole stage, and a mosaic of small adjacent cells, where a few pixels either side of a border file the card in the wrong folder.

It is a plugin, not an option — the projection, the velocity fit and the debug renderer are a kilobyte that a page which never throws a card should not be carrying:

import { flick } from '@trieur/core/flick';

new Deck(el, {
  plugins: [flick({
    ms: 170,       // how far ahead the release is projected
    decay: 0.994,  // the same number as a deceleration rate, if you prefer
    min: 0.6,      // px/ms below which a release is an ordinary drop
    bias: 0.4,     // how much wider the model's suggestion catches, in tiles
    debug: false,  // draw the vector and the gravity well while tuning
  })],
});

Where it comes to rest

The projection is the one a scroll view uses. Velocity decays exponentially — v(t) = v₀·λᵗ with λ per millisecond — and the whole trip integrates to:

distance = v₀ · λ / (1 − λ)

That fraction has units of milliseconds, which is why the knob is a duration. flickMs: 170 is λ ≈ 0.994, between iOS’s fast scrolling (0.99) and its normal one (0.998). A card is lighter than a scroll view and should not sail for half a second.

The projection is capped at the stage diagonal: past the edge every extra pixel aims at the same zone anyway, and an uncapped throw off a fast trackpad lands in another postcode.

The speed at lift-off

Not an average of the drag. Velocity is fitted from the last 100 ms of raw pointer samples with a weighted quadratic — the strategy Android’s VelocityTracker uses — because a fling is usually still accelerating when the finger leaves, and a straight average reports the speed of the gesture’s middle rather than its end.

Three details that came from real devices rather than from theory:

The model’s thumb on the scale

The part that feels like magic is borrowed from the iPhone keyboard. After you type kno, the w key’s hit area grows — the key itself does not move a pixel, and you never see it happen.

Here the same trick: the tiles stay where they are, and the zone the model suggests catches throws that land wide of it, in proportion to how sure it is:

cost(zone) = distance(landing, tile) − flickBias × score × tileSize

At flickBias: 0.4, a suggestion the model is certain of quietly widens by nearly half a tile. At 0 there is no magic left, only geometry. The debug view draws the well as a dashed circle, so you can see exactly how much help you are getting.

What the throw is aimed at

Three rules, in order, and the order is the whole design:

  1. Below flickMin, it is a drop. Nothing else applies. The default is 0.6 px/ms — a deliberate flick, not a careful drag that happened to still be moving. Set it too low and every release becomes a throw, which is the fastest way to make a sorter feel possessed.
  2. If the projection lands on the stage, the region under it wins. The carving is what you are looking at, and a short throw is a drop with follow-through — it should file where the card visibly went.
  3. Past the edge, the nearest tile to the ray. Not to the landing point: a projection that overshoots by 400px is still pointing at the same tile, and measuring to the point is what makes a fast flick pick a corner instead of the target it was aimed at.

One rule survives all three: a zone the gesture is heading away from is never a candidate. A card thrown upwards is not filed into the folder below it, whatever the arithmetic says.

The whole resolution lives in throw.ts, away from the DOM, and is exported — resolveThrow(input) takes points and velocities and returns a zone with a reason ('slow' | 'region' | 'ray' | 'nothing'). It has its own tests, which is the only way to know that a projection is right rather than plausible.

Tuning it

The right numbers depend on your zones — a dock of six columns wants little reach, a mosaic of twenty cells wants a lot. Turn flickDebug on, put the four values on sliders (the demos do), and throw thirty cards. What you are looking for:

SymptomKnob
Careful drags get thrownraise flickMin
Throws overshoot into the far zonelower flickMs
Distant zones still need a long dragraise flickMs
The suggestion catches too muchlower flickBias

Every release the plugin looks at is reported, thrown or not, so you can measure rather than guess:

el.addEventListener('trieur:throw', (e) => {
  const { speed, carried, thrown, why, zone } = e.detail;
});

(The deck’s own trieur:release still fires for every drag, with { item, zone, dist, cancelled } — the throw’s numbers belong to the plugin that computed them.)