@humanforest/charts 0.3.7 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,93 @@
2
2
 
3
3
  Every @humanforest package shares one version, so one entry covers all seven packages. Newest first.
4
4
 
5
+ ## 0.5.0 — 2026-10-25
6
+
7
+ ### Breaking changes
8
+
9
+ - **ui**: let a sidebar nav item carry its own badge
10
+
11
+ ### Features
12
+
13
+ - **ui**: let a HUD card centre between its band's rails
14
+
15
+ ### Fixes
16
+
17
+ - **ui**: keep `center` off the HUD's bottom card
18
+ - **ui**: drop the page-level scrollbar gutter
19
+ - **ui**: the map search glyph is sized, not padded
20
+
21
+ ## 0.4.0 — 2026-09-15
22
+
23
+ ### Features
24
+
25
+ - **docs**: the llms mirror prints a section kicker under its heading
26
+ - **frames**: FFitFrame fits a slot that sizes itself
27
+ - **charts**: a sparkline's area fades from its line to the floor
28
+ - **charts**: the calendar month rule boxes each month in a toned line
29
+ - **docs**: the llms mirror heads a non-prop table by its own column
30
+ - **docs**: the llms mirror names the module a page destructures
31
+ - **docs**: the llms mirror renders section loops
32
+ - **docs**: the llms mirror resolves plain strings a page imports
33
+ - **docs**: DemoStage puts a demo's controls above the specimen
34
+ - **docs**: the llms mirror names each code tab in a ProseCodeGroup
35
+ - **docs**: the llms mirror renders guidance cards and imported arrays
36
+
37
+ ### Fixes
38
+
39
+ - **ui**: the scroll fade follows content that changes after mount
40
+ - **ui**: map drawing places a corner only for a press on the map
41
+ - **ui**: the scale bar measures at the centre and hides off the globe
42
+ - **ui**: geolocate stays active through a location error while tracking
43
+ - **ui**: map search names only a rendered row in aria-activedescendant
44
+ - **ui**: a context menu that reopens elsewhere takes focus again
45
+ - **ui**: map overlays add layers after the frame, not during placement
46
+ - **ui**: the map context menu wires nothing on a non-interactive map
47
+ - **ui**: map search rings kept focus and never sends a pair to suggest
48
+ - **ui**: fullscreen reads nothing without a fullscreen API
49
+ - **ui**: geolocate is a plain action with track off
50
+ - **ui**: coordinates read Mapbox's 512px world and name the copied pair
51
+ - **docs**: chord, treemap and sankey demos keep their heights
52
+ - **charts**: resolveVar leaves the document alone without a DOM
53
+ - **docs**: the battery-swap stage keeps its start edge reachable
54
+ - **docs**: foundations and layout diagrams fit a phone
55
+ - **docs**: device frames scale down to fit a phone's pane
56
+ - **docs**: component specimens fit a phone's preview pane
57
+ - **filter**: the chip's number segments drop their steppers
58
+ - **docs**: the table page's pager drops its arrows on a phone
59
+ - **filter**: a chip wider than its bar wraps its segments
60
+ - **tokens**: the display role steps down to 4xl below 40rem
61
+ - **tokens**: long inline code wraps instead of overflowing its column
62
+ - **docs**: long inline code and prop types wrap instead of widening main
63
+ - **ui**: a vehicle mark's badge outline follows its edge
64
+ - **ui**: ramp swatches abut and round only the ramp's ends
65
+ - **docs**: a context switch keeps a page's live demos mounted
66
+ - **ui**: a bound popup closes on a second click and unbinds on unmount
67
+ - **ui**: a marker is a button only when it acts as one
68
+ - **ui**: FMap applies every padding side and logs each map error
69
+ - **ui**: a track slice no longer bridges a signal gap
70
+ - **ui**: track playback keeps the playhead when the speed changes
71
+ - **ui**: balance maps paint by magnitude, and legends label exactly
72
+ - **charts**: the tooltip snap looks for its panel on pointer entry
73
+ - **ui**: unavailable reasons name a state by its label
74
+ - **ui**: zones ignore a kind they do not know
75
+ - **ui**: a selected vehicle takes its new state on resync
76
+ - **docs**: a cross-page anchored link lands on its target
77
+ - **ui**: useMapReady never runs for a caller that has unmounted
78
+ - **tokens**: area fills keep their opacity under data-theme dark
79
+ - **charts**: crosshair dots sit on stacked band tops
80
+ - **charts**: a fitted London map labels only the areas it draws
81
+ - **charts**: a missing reading breaks a line sparkline
82
+ - **charts**: a dotted map's missing values add nothing
83
+ - **charts**: a selectable London map is reachable at every size
84
+ - **charts**: cell tracks and grids take one Tab stop, props read live
85
+ - **docs**: urgent vehicles draw on top of the fleet
86
+ - **ui**: alt shortcuts select, and deleted features leave the selection
87
+ - **ui**: a crossing edit springs back, and a drag reports once
88
+ - **ui**: selected markers rise, popups are named, one button opens them
89
+ - **ui**: the coordinate tile stays copyable as the pointer moves onto it
90
+ - **ui**: the scale bar's fill is as long as the distance it names
91
+
5
92
  ## 0.3.7 — 2026-09-13
6
93
 
7
94
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanforest/charts",
3
- "version": "0.3.7",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -29,7 +29,7 @@
29
29
  "vue": "^3.5.22"
30
30
  },
31
31
  "dependencies": {
32
- "@humanforest/tokens": "^0.3.7"
32
+ "@humanforest/tokens": "^0.5.0"
33
33
  },
34
34
  "publishConfig": {
35
35
  "access": "public"
@@ -1,14 +1,16 @@
1
1
  <script setup lang="ts" generic="D extends Record<string, unknown>">
2
- // Days as cells: weeks are columns, weekdays are rows. A year of ride volume, or a year of one
3
- // station's daily state. Colour resolution is useCellScale's; this component owns the calendar.
2
+ // Days as cells: weeks are columns, weekdays are rows. A year of trip volume, or a year of one
3
+ // parking place's daily state. Colour resolution is useCellScale's; this component owns the
4
+ // calendar.
4
5
  //
5
6
  // The grid is built from the date window, not from the data, so a day with no datum is a real empty
6
7
  // cell in the right place rather than a missing column. Days are bucketed by local calendar date —
7
8
  // a timestamp is floored to its day before lookup, so two events on one day land together.
8
9
  import { computed, ref } from 'vue';
9
10
  import type { BulletLegendItemInterface } from '@unovis/ts';
10
- import { useCellScale, CELL_EMPTY_COLOR, type SequentialRamp } from './useCellScale';
11
+ import { useCellScaleProps, CELL_EMPTY_COLOR, type SequentialRamp } from './useCellScale';
11
12
  import { CELL_SIZES, orElse, type CellSize } from './cellSize';
13
+ import { rovingIndex } from './rovingFocus';
12
14
  import FCellLegend from './FCellLegend.vue';
13
15
 
14
16
  const props = withDefaults(
@@ -39,13 +41,15 @@ const props = withDefaults(
39
41
  cellGap?: number;
40
42
  radius?: number;
41
43
  monthLabels?: boolean;
42
- /** Rule along the month boundary. It steps day by day, so a month opening midweek is cut where
43
- * it actually opens rather than at the column edge. */
44
+ /** Box around each month. It steps day by day, so a month opening midweek is cut where it
45
+ * actually opens rather than at the column edge. */
44
46
  monthSeparators?: boolean;
45
47
  weekdayLabels?: boolean;
46
48
  legend?: boolean;
47
- /** Hover and focus text. Given one, cells become focusable buttons and get the house bubble.
48
- * Suppressed at xs and sm, where the target is too small to hit reliably. */
49
+ /** Hover and focus text. Given one, cells become focusable buttons and get the house bubble; the
50
+ * grid is one Tab stop, ArrowUp and ArrowDown step a day, ArrowLeft and ArrowRight a week, and
51
+ * Home and End jump to the first and last day. Suppressed at xs and sm, where the target is too
52
+ * small to hit reliably. */
49
53
  tooltip?: (d: D | undefined, date: Date) => string;
50
54
  ariaLabel?: string;
51
55
  }>(),
@@ -128,16 +132,7 @@ const weeks = computed<{ date: Date; inRange: boolean }[][]>(() => {
128
132
  return out;
129
133
  });
130
134
 
131
- const scale = useCellScale<D>({
132
- mode: props.mode,
133
- states: computed(() => props.states),
134
- ramp: computed(() => props.ramp ?? ('forest' as SequentialRamp)),
135
- domain: computed(() => props.domain),
136
- steps: computed(() => props.steps),
137
- data: computed(() => props.data),
138
- value: props.value,
139
- state: props.state,
140
- });
135
+ const scale = useCellScaleProps<D>(props);
141
136
 
142
137
  const datumFor = (d: Date) => byDay.value.get(keyOf(d));
143
138
  const fillFor = (cell: { date: Date; inRange: boolean }) =>
@@ -158,47 +153,58 @@ const pitch = computed(() => cellSize.value + cellGap.value);
158
153
  const gridWidth = computed(() => weeks.value.length * pitch.value - cellGap.value);
159
154
  const gridHeight = computed(() => 7 * pitch.value - cellGap.value);
160
155
 
161
- // Where each month opens, in grid coordinates. One source for both the label and the rule: the
162
- // label used to be placed on the week whose FIRST day fell in the new month, which puts it up to
163
- // six days late whenever a month opens midweek.
156
+ // The column each month opens in. The label used to be placed on the week whose FIRST day fell in
157
+ // the new month, which puts it up to six days late whenever a month opens midweek.
164
158
  const monthStarts = computed(() => {
165
- const out: { wi: number; di: number; label: string; first: boolean }[] = [];
159
+ const out: { wi: number; label: string }[] = [];
166
160
  let prevMonth: number | null = null;
167
161
  weeks.value.forEach((week, wi) => {
168
- week.forEach((cell, di) => {
162
+ week.forEach((cell) => {
169
163
  if (!cell.inRange) return;
170
164
  const m = cell.date.getMonth();
171
- if (prevMonth === null) out.push({ wi, di, label: monthLabel(cell.date), first: true });
172
- else if (m !== prevMonth) out.push({ wi, di, label: monthLabel(cell.date), first: false });
165
+ if (m !== prevMonth) out.push({ wi, label: monthLabel(cell.date) });
173
166
  prevMonth = m;
174
167
  });
175
168
  });
176
169
  return out;
177
170
  });
178
171
 
179
- // The boundary between two months, as one continuous path each.
172
+ // A box around each month: an edge wherever a day's neighbour — a row up or down, a week left or
173
+ // right — is another month or outside the window. That one test closes every box top and bottom,
174
+ // steps it round a partial first or last week, and draws the frontier between two months.
180
175
  //
181
176
  // Time runs down a column then into the next, so a month that opens midweek does NOT split its
182
177
  // column into left and right: the days above the opening row in every LATER column already belong
183
- // to the new month. The frontier is therefore a Z — down the right edge of the opening column to
184
- // the opening row, one column left, then down to the bottom — not a per-cell outline. A month
185
- // opening on the week-start row degenerates to a single straight vertical.
178
+ // to the new month. The frontier comes out as a Z — down the right edge of the opening column to
179
+ // the opening row, one column left, then down to the bottom. A month opening on the week-start row
180
+ // gets a straight vertical. A frontier is emitted from both sides; the two segments coincide.
186
181
  //
187
182
  // Coordinates land on gap midpoints (w * pitch - cellGap/2), so the rule sits centred between two
188
- // cells rather than against either one, and one path per boundary welds its own corners.
183
+ // cells rather than against either one. Square caps weld the corners between segments.
189
184
  const monthPaths = computed(() => {
190
185
  if (!props.monthSeparators) return '';
186
+ const p = pitch.value;
191
187
  const half = cellGap.value / 2;
192
- return monthStarts.value
193
- .filter((m) => !m.first)
194
- .map(({ wi, di }) => {
195
- const xLeft = wi * pitch.value - half;
196
- if (di === 0) return `M${xLeft},0V${gridHeight.value}`;
197
- const xRight = (wi + 1) * pitch.value - half;
198
- const yCut = di * pitch.value - half;
199
- return `M${xRight},0V${yCut}H${xLeft}V${gridHeight.value}`;
200
- })
201
- .join(' ');
188
+ const monthAt = (wi: number, di: number) => {
189
+ const cell = weeks.value[wi]?.[di];
190
+ return cell?.inRange ? cell.date.getMonth() : -1;
191
+ };
192
+ const d: string[] = [];
193
+ weeks.value.forEach((week, wi) => {
194
+ week.forEach((cell, di) => {
195
+ if (!cell.inRange) return;
196
+ const m = cell.date.getMonth();
197
+ const x0 = wi * p - half;
198
+ const x1 = x0 + p;
199
+ const y0 = di * p - half;
200
+ const y1 = y0 + p;
201
+ if (monthAt(wi, di - 1) !== m) d.push(`M${x0},${y0}H${x1}`);
202
+ if (monthAt(wi, di + 1) !== m) d.push(`M${x0},${y1}H${x1}`);
203
+ if (monthAt(wi - 1, di) !== m) d.push(`M${x0},${y0}V${y1}`);
204
+ if (monthAt(wi + 1, di) !== m) d.push(`M${x1},${y0}V${y1}`);
205
+ });
206
+ });
207
+ return d.join('');
202
208
  });
203
209
 
204
210
  const dateLabel = (d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
@@ -228,6 +234,30 @@ const hideTip = () => {
228
234
  tipOpen.value = false;
229
235
  };
230
236
 
237
+ // Roving focus: the grid is one Tab stop, held by the day focused last. A cell's flat index is
238
+ // week * 7 + weekday and the in-range days are one unbroken run of it, so the arrow keys step a day
239
+ // (up, down) or a week (left, right) along that run, and Home and End jump to its ends.
240
+ const focusRange = computed<[number, number]>(() => {
241
+ const cells = weeks.value.flat();
242
+ const first = cells.findIndex((c) => c.inRange);
243
+ return [first, first + cells.filter((c) => c.inRange).length - 1];
244
+ });
245
+ const focusIndex = ref(0);
246
+ const tabStop = computed(() => Math.min(Math.max(focusIndex.value, focusRange.value[0]), focusRange.value[1]));
247
+ const onFocus = (event: Event, index: number, cell: { date: Date }) => {
248
+ focusIndex.value = index;
249
+ showTip(event, labelFor(cell));
250
+ };
251
+ const onKeydown = (event: KeyboardEvent, index: number) => {
252
+ const [first, last] = focusRange.value;
253
+ const next = rovingIndex(event, index, first, last, 7, 1);
254
+ if (next === null) return;
255
+ event.preventDefault();
256
+ // The in-range days are the grid's only buttons, in flat-index order.
257
+ const buttons = (event.currentTarget as Element).closest('[role="group"]')?.querySelectorAll('button');
258
+ buttons?.[next - first]?.focus();
259
+ };
260
+
231
261
  const hasEmpty = computed(() =>
232
262
  weeks.value.some((w) => w.some((c) => c.inRange && scale.isEmpty(datumFor(c.date) ?? null))),
233
263
  );
@@ -235,7 +265,12 @@ const hasEmpty = computed(() =>
235
265
 
236
266
  <template>
237
267
  <div class="flex w-full flex-col gap-3">
238
- <div class="overflow-x-auto">
268
+ <!-- The month boxes sit half a gap outside the cells, past this scroller's clip. Padding out and
269
+ margin back in gives them room without moving the grid. -->
270
+ <div
271
+ class="overflow-x-auto"
272
+ :style="monthSeparators ? { padding: `${cellGap}px`, margin: `-${cellGap}px` } : undefined"
273
+ >
239
274
  <div class="flex" :style="{ gap: `${cellGap}px` }">
240
275
  <!-- Weekday gutter. justify-end pins the rows to the grid's bottom, so they line up
241
276
  whether or not a month-label row sits above. -->
@@ -288,18 +323,20 @@ const hasEmpty = computed(() =>
288
323
  }"
289
324
  :aria-label="interactive && cell.inRange ? labelFor(cell) : undefined"
290
325
  :aria-hidden="interactive && cell.inRange ? undefined : 'true'"
326
+ :tabindex="interactive && cell.inRange ? (wi * 7 + di === tabStop ? 0 : -1) : undefined"
291
327
  @mouseenter="interactive && cell.inRange && showTip($event, labelFor(cell))"
292
328
  @mouseleave="hideTip"
293
- @focus="interactive && cell.inRange && showTip($event, labelFor(cell))"
329
+ @focus="interactive && cell.inRange && onFocus($event, wi * 7 + di, cell)"
294
330
  @blur="hideTip"
331
+ @keydown="interactive && cell.inRange && onKeydown($event, wi * 7 + di)"
295
332
  />
296
333
  </div>
297
334
  </div>
298
335
 
299
- <!-- One path for every boundary, over the cells. pointer-events none so it never takes
300
- a hover from the cell beneath; crispEdges so a 1px rule stays 1px off-integer.
301
- Dimmed text rather than a border token: the border tokens sit around 1.5:1 on the
302
- page and vanish between saturated cells. -->
336
+ <!-- One path for every box, over the cells. pointer-events none so it never takes a
337
+ hover from the cell beneath; crispEdges so a 1px rule stays 1px off-integer.
338
+ Toned text rather than a border token: the border tokens sit around 1.5:1 on the
339
+ page and vanish between saturated cells, and dimmed was still too faint. -->
303
340
 
304
341
  <svg
305
342
  v-if="monthSeparators"
@@ -312,8 +349,9 @@ const hasEmpty = computed(() =>
312
349
  :d="monthPaths"
313
350
  fill="none"
314
351
  stroke-width="1"
352
+ stroke-linecap="square"
315
353
  shape-rendering="crispEdges"
316
- style="stroke: var(--ui-text-dimmed)"
354
+ style="stroke: var(--ui-text-toned)"
317
355
  />
318
356
  </svg>
319
357
  </div>
@@ -10,20 +10,18 @@ import { Position } from '@unovis/ts';
10
10
  import type {
11
11
  AxisConfigInterface,
12
12
  BulletLegendItemInterface,
13
+ Crosshair,
13
14
  TooltipConfigInterface,
14
15
  XYContainerConfigInterface,
15
16
  } from '@unovis/ts';
16
17
  // Relative file imports (not the package barrel): the barrel exports this component, and a
17
18
  // self-import through it would create a module cycle.
18
19
  import { chartLabel, fromCategories } from './fromCategories';
20
+ import { crosshairConfig } from './crosshairConfig';
19
21
  import { forestTooltip, type ForestTooltipOptions } from './forestTooltip';
20
22
  import { motionDuration } from './motionDuration';
21
- import {
22
- axisX as axisXPreset,
23
- axisY as axisYPreset,
24
- tooltipDefaults,
25
- tooltipContainer,
26
- } from './presets';
23
+ import { indexAxes, indexContainer } from './frameIndex';
24
+ import { tooltipDefaults, tooltipContainer } from './presets';
27
25
  import { useThemeVersion } from './useThemeVersion';
28
26
  import { useTooltipSnap, nextTipId } from './snapTooltip';
29
27
 
@@ -58,9 +56,9 @@ const props = withDefaults(
58
56
  crosshair?: boolean;
59
57
  /** Title/value formatting for the house crosshair template. */
60
58
  format?: ForestTooltipOptions<Datum>;
61
- /** The mark runs horizontally (set `orientation` on the mark itself). The frame's index
62
- * machinery — tick pinning, the edge gutter, grid-line defaults — follows the category
63
- * dimension onto y; formatters stay yours to place per axis. */
59
+ /** The mark runs horizontally (set `orientation` on the mark itself). Tick pinning and the
60
+ * edge gutter move onto y, the category dimension, and the grid lines onto x; formatters stay
61
+ * yours to place per axis. */
64
62
  horizontal?: boolean;
65
63
  legend?: boolean;
66
64
  /** Where the legend sits. `bottom` (default) renders centred under the chart, caption-style;
@@ -98,43 +96,20 @@ defineSlots<{
98
96
 
99
97
  const acc = computed(() => fromCategories<Datum>(props.categories, props.data, props.xKey));
100
98
 
101
- // Index charts get tick positions at the row indices — without this the axis invents fractional
102
- // ticks between rows. The pin sits on the category axis: x normally, y when `horizontal` (Unovis
103
- // swaps which scale carries the index). Grid lines follow the value axis the same way. An xKey
104
- // chart scales its own axis.
105
- const indexTicks = computed(() =>
106
- props.xKey ? undefined : { tickValues: props.data.map((_, i) => i) },
107
- );
108
- const xAxisConfig = computed<Partial<AxisConfigInterface<Datum>>>(() => ({
109
- ...axisXPreset,
110
- ...(props.horizontal ? { gridLine: true } : indexTicks.value),
111
- ...(props.axisX || {}),
112
- }));
113
- const yAxisConfig = computed<Partial<AxisConfigInterface<Datum>>>(() => ({
114
- ...axisYPreset,
115
- ...(props.horizontal ? { gridLine: false, ...indexTicks.value } : {}),
116
- ...(props.axisY || {}),
117
- }));
118
-
119
- // Index charts also get half a row of outer gutter: the category domain extends past the first
120
- // and last rows so edge marks (bars especially) stop sitting flush against the plot edges. Ticks
121
- // stay on the row indices, and the gutter follows the category dimension under `horizontal`. An
122
- // xKey chart plots a real field and keeps the library's exact-extent default; an explicit
123
- // `container` domain overrides this like any other container key.
124
- const containerConfig = computed<Partial<XYContainerConfigInterface<Datum>>>(() => {
125
- const gutter: [number, number] = [-0.5, props.data.length - 0.5];
126
- return {
127
- ...(props.xKey || props.data.length === 0
128
- ? {}
129
- : props.horizontal
130
- ? { yDomain: gutter }
131
- : { xDomain: gutter }),
132
- ...(props.container || {}),
133
- };
134
- });
99
+ // Index charts pin the category axis ticks to the row indices, extend its domain half a row past
100
+ // each end, and leave the grid to the value axis; `horizontal` moves the ticks and the gutter onto
101
+ // y and the grid onto x. The rules live in frameIndex.ts. The container keeps a computed of its
102
+ // own: VisXYContainer updates on every new config object, where VisAxis compares its props deeply.
103
+ const axes = computed(() => indexAxes<Datum>(props));
104
+ const containerConfig = computed(() => indexContainer<Datum>(props));
135
105
 
136
106
  const tipFn = computed(() => forestTooltip<Datum>(props.categories, props.format));
137
107
 
108
+ const crosshairRef = ref<{ component?: Crosshair<Datum> }>();
109
+ const crosshairProps = computed(() =>
110
+ crosshairConfig(acc.value, () => crosshairRef.value?.component?.accessors),
111
+ );
112
+
138
113
  // The panel is snapped to the datum by useTooltipSnap below, which rewrites its position after
139
114
  // Unovis places it. What Unovis is configured to do here is therefore only what shows when the
140
115
  // snap stands down (`followCursor`), plus the show/hide and content it always owns: pointer
@@ -180,8 +155,8 @@ const repaintKey = computed(() =>
180
155
  v-bind="{ ...containerConfig, ...$attrs }"
181
156
  >
182
157
  <slot name="mark" :data="data" :x="acc.xAccessor" :y="acc.yAccessors" :colors="acc.colors" />
183
- <VisAxis v-if="axisX !== false" v-bind="xAxisConfig" />
184
- <VisAxis v-if="axisY !== false" v-bind="yAxisConfig" />
158
+ <VisAxis v-if="axisX !== false" v-bind="axes.axisX" />
159
+ <VisAxis v-if="axisY !== false" v-bind="axes.axisY" />
185
160
  <template v-if="tooltip !== false">
186
161
  <!-- duration 0 is deliberate, and load-bearing twice over. Unovis animates the crosshair
187
162
  line's x through a transition, so it eases toward the row the pointer just snapped to
@@ -190,9 +165,8 @@ const repaintKey = computed(() =>
190
165
  tooltip's anchor. Easing it would put the panel on the wrong side of the plot. -->
191
166
  <VisCrosshair
192
167
  v-if="crosshair"
193
- :x="acc.xAccessor"
194
- :y="acc.yAccessors"
195
- :color="acc.colors"
168
+ ref="crosshairRef"
169
+ v-bind="crosshairProps"
196
170
  :template="tipFn"
197
171
  :duration="0"
198
172
  />
@@ -3,8 +3,8 @@
3
3
  // FStatusTrack puts cells in a sequence, FCalendarGrid puts them on a date grid, this puts them on a
4
4
  // geography. Colour resolution is useCellScale's; this component owns the binning and the layout.
5
5
  //
6
- // It is not a map. There is no pan, no zoom and no projection at read time — a dot is roughly a
7
- // square kilometre and locates nothing. Real geography is Mapbox's job. This answers "where in
6
+ // It is not a map. There is no pan, no zoom and no projection at read time — a dot on the default
7
+ // grid covers about 0.37 km² and locates nothing. Real geography is Mapbox's job. This answers "where in
8
8
  // London" at a glance, with no tiles and no network.
9
9
  import { computed, ref, shallowRef, watchEffect } from 'vue';
10
10
  import { useCellScale, type SequentialRamp } from './useCellScale';
@@ -13,7 +13,7 @@ import {
13
13
  LONDON_GRID,
14
14
  RIVER_COLOR,
15
15
  loadLondonGrid,
16
- dotAt,
16
+ binPoints,
17
17
  dotKey,
18
18
  type DotDensity,
19
19
  type LondonGrid,
@@ -31,7 +31,7 @@ const props = withDefaults(
31
31
  points: P[];
32
32
  lat: (p: P) => number;
33
33
  lng: (p: P) => number;
34
- /** Summed per dot as `value(p) ?? 1`, so the default reading is a count of points. */
34
+ /** Summed per dot. Unset, each point counts one; a null, undefined or non-finite return adds nothing. */
35
35
  value?: (p: P) => number | null | undefined;
36
36
  ramp?: SequentialRamp;
37
37
  /** Intensity domain, in the caller's units. Two maps compared side by side must both set it. */
@@ -121,22 +121,11 @@ const land = computed(() => new Set(landDots.value.map(([c, r]) => dotKey(c, r))
121
121
 
122
122
  // Points are summed into their dot. A point outside the boundary, or on the river, has nowhere to
123
123
  // land — those are counted rather than quietly absorbed, so a caller feeding the wrong city or the
124
- // wrong coordinate order can see it. The river is drawn but is not a bucket: a ride does not start
124
+ // wrong coordinate order can see it. The river is drawn but is not a bucket: a trip does not start
125
125
  // in the Thames, and letting one land there would put a reading on a dot that cannot have one.
126
- const binned = computed(() => {
127
- const totals = new Map<string, number>();
128
- let dropped = 0;
129
- for (const p of props.points) {
130
- const at = dotAt(grid.value, props.lat(p), props.lng(p));
131
- const key = at && dotKey(at[0], at[1]);
132
- if (!key || !land.value.has(key)) {
133
- dropped++;
134
- continue;
135
- }
136
- totals.set(key, (totals.get(key) ?? 0) + (props.value?.(p) ?? 1));
137
- }
138
- return { totals, dropped };
139
- });
126
+ const binned = computed(() =>
127
+ binPoints(grid.value, land.value, props.points, props.lat, props.lng, props.value),
128
+ );
140
129
 
141
130
  // Every land dot gets a cell, whether or not a point reached it.
142
131
  //
@@ -155,9 +144,9 @@ const cells = computed<DotInfo[]>(() =>
155
144
 
156
145
  // How a total is spaced along the ramp.
157
146
  //
158
- // Density is heavy-tailed, and linearly spacing a heavy tail produces a blank map: with a typical
159
- // week of rides the median dot holds 1 and the busiest holds over a hundred, so five equal buckets
160
- // put roughly ninety per cent of the city in the palest one and the picture says nothing. Compress
147
+ // Density is heavy-tailed, and linearly spacing a heavy tail produces a blank map: in a week of
148
+ // trip starts the median dot can hold 1 and the busiest 50, so five equal buckets put over ninety
149
+ // per cent of the city in the palest one and the picture says nothing. Compress
161
150
  // the tail and the structure appears. Nothing is compressed by default — a transform the caller did
162
151
  // not ask for would quietly change what the ramp claims — but a density map usually wants one.
163
152
  const spaced = (v: number) =>
@@ -226,7 +215,7 @@ const byPosition = computed(() => {
226
215
  const mapLabel = computed(() => {
227
216
  if (props.ariaLabel) return props.ariaLabel;
228
217
  // The busiest dot in the caller's units. scale.domain is in spaced units and would report a
229
- // logarithm as though it were a ride count.
218
+ // logarithm as though it were a count.
230
219
  const hi = props.domain?.[1] ?? cells.value.reduce((m, c) => (c.value > m ? c.value : m), 0);
231
220
  const placed = props.points.length - binned.value.dropped;
232
221
  return `London, as a grid of dots shaded by density. The busiest holds ${hi.toLocaleString('en-GB')}, across ${placed.toLocaleString('en-GB')} of ${props.points.length.toLocaleString('en-GB')} points.`;