@heyramzi/ui 2.14.0 → 2.15.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.
@@ -10,20 +10,33 @@ export interface Smoothing {
10
10
  kind?: SeriesKind;
11
11
  }
12
12
 
13
+ /** Raw days, a trailing average, or a level read once a week. */
13
14
  export type SmoothMode = "raw" | "rolling" | "weekly";
14
15
 
16
+ export interface Smoothed<TData> {
17
+ points: TData[];
18
+ mode: SmoothMode;
19
+ /** How many days a rolling point averages: 7 up to a quarter, 28 past it. */
20
+ window: number;
21
+ }
22
+
15
23
  /** The tooltip's name for a drawn point, beside its date. */
16
- export const ROLLING_LABEL = "7-day average";
24
+ export const averageLabel = (days: number) => `${days}-day average`;
25
+ export const ROLLING_LABEL = averageLabel(7);
17
26
  export const weekOf = (start: string) => `Week of ${start}`;
18
- export const averageTo = (end: string) => `${ROLLING_LABEL} to ${end}`;
27
+ export const averageTo = (end: string, days = 7) => `${averageLabel(days)} to ${end}`;
28
+ /** What a point the collector never measured reads as, where a number would claim a 0. */
29
+ export const NO_DATA = "No data";
19
30
 
20
31
  const DAY = 86_400_000;
21
- const WINDOW = 7;
32
+ const WEEK = 7;
33
+ /** A year at 7 days still drew one-day spikes as humps; 28 days keeps the growth and drops the spikes. */
34
+ const LONG_WINDOW = 28;
22
35
 
23
36
  // =========================
24
37
  // FUNCTIONS
25
38
  // =========================
26
- /** About 30 to 60 marks across a chart: raw to a week, a 7-day average to a quarter, whole weeks past it. */
39
+ /** About 30 to 60 marks across a chart: raw to a fortnight, a daily average to a quarter, weekly past it. */
27
40
  export function smoothMode(rangeDays: number): SmoothMode {
28
41
  if (rangeDays <= 14) return "raw";
29
42
  if (rangeDays <= 120) return "rolling";
@@ -34,6 +47,13 @@ function timeOf(value: unknown): number {
34
47
  return value instanceof Date ? value.getTime() : new Date(String(value)).getTime();
35
48
  }
36
49
 
50
+ /** Null, undefined or not a number: a day nobody measured. An absent row still counts as 0. */
51
+ function measured(value: unknown): number | null {
52
+ if (value == null) return null;
53
+ const n = Number(value);
54
+ return Number.isFinite(n) ? n : null;
55
+ }
56
+
37
57
  /** The bucket's start in the same form the series uses, so the axis and the formatter see no change. */
38
58
  function sameForm(sample: unknown, time: number): unknown {
39
59
  if (sample instanceof Date) return new Date(time);
@@ -41,66 +61,100 @@ function sameForm(sample: unknown, time: number): unknown {
41
61
  return String(sample).length === 10 ? iso.slice(0, 10) : iso;
42
62
  }
43
63
 
64
+ const daysBetween = (from: number, to: number) => Math.round((to - from) / DAY);
65
+
66
+ /**
67
+ * A trailing mean over `window` days, drawn on the days `keep` picks.
68
+ *
69
+ * A missing value is skipped, never averaged in as 0, and a window with fewer measured days than
70
+ * missing ones draws a gap: one partial day after a 12-day outage is not a week. Days before a
71
+ * series' first measured value are not tracked yet, so they neither draw nor count as missing.
72
+ */
73
+ function trailingMean<TData extends Record<string, unknown>>(
74
+ data: TData[],
75
+ times: number[],
76
+ yKeys: string[],
77
+ window: number,
78
+ keep: (i: number) => boolean,
79
+ ): TData[] {
80
+ const firstSeen = Object.fromEntries(
81
+ yKeys.map((k) => [k, times[data.findIndex((d) => measured(d[k]) !== null)] ?? Infinity]),
82
+ );
83
+ const out: TData[] = [];
84
+ data.forEach((d, i) => {
85
+ if (!keep(i)) return;
86
+ const row: Record<string, unknown> = { ...d };
87
+ for (const k of yKeys) {
88
+ if (times[i] < firstSeen[k]) {
89
+ row[k] = null;
90
+ continue;
91
+ }
92
+ let sum = 0;
93
+ let missing = 0;
94
+ for (let j = i; j >= 0 && times[i] - times[j] < window * DAY; j--) {
95
+ const value = measured(data[j][k]);
96
+ if (value === null) missing += times[j] >= firstSeen[k] ? 1 : 0;
97
+ else sum += value;
98
+ }
99
+ // Divided by the tracked days in the window, so a row absent from a sparse series counts as 0.
100
+ const days = Math.min(window, daysBetween(Math.max(times[0], firstSeen[k]), times[i]) + 1);
101
+ const seen = days - missing;
102
+ row[k] = seen > 0 && seen * 2 >= days ? sum / seen : null;
103
+ }
104
+ out.push(row as TData);
105
+ });
106
+ return out;
107
+ }
108
+
44
109
  /**
45
- * The line a chart draws: a trailing 7-day mean, or 7-day buckets counted back from the last day.
46
- * Totals stay the caller's raw sums; this only moves the drawn points.
110
+ * The line a chart draws. Up to a quarter, a trailing 7-day mean per day. Past it, a trailing
111
+ * 28-day mean read once a week from the last day, so the axis stays in per-day units at every
112
+ * range; a level reads its last value each week instead. A missing value (null) is a gap in the
113
+ * line, never a 0. Totals stay the caller's raw sums; this only moves the drawn points.
47
114
  */
48
115
  export function smoothSeries<TData extends Record<string, unknown>>(
49
116
  data: TData[],
50
117
  xKey: keyof TData & string,
51
118
  yKeys: (keyof TData & string)[],
52
119
  { rangeDays, kind = "count" }: Smoothing,
53
- ): { points: TData[]; mode: SmoothMode } {
120
+ ): Smoothed<TData> {
54
121
  const mode = smoothMode(rangeDays);
55
- if (mode === "raw" || data.length < WINDOW) return { points: data, mode: "raw" };
122
+ if (mode === "raw" || data.length < WEEK) return { points: data, mode: "raw", window: 1 };
56
123
  const times = data.map((d) => timeOf(d[xKey]));
124
+ const last = times[times.length - 1];
57
125
 
58
- if (mode === "rolling") {
59
- // A level has no daily noise to average, and a mean draws slopes between its steps.
60
- if (kind === "level") return { points: data, mode: "raw" };
61
- let from = 0;
62
- const sums = Object.fromEntries(yKeys.map((k) => [k, 0])) as Record<string, number>;
63
- const points = data.map((d, i) => {
64
- for (const k of yKeys) sums[k] += Number(d[k] ?? 0);
65
- while (times[i] - times[from] >= WINDOW * DAY) {
66
- for (const k of yKeys) sums[k] -= Number(data[from][k] ?? 0);
67
- from += 1;
68
- }
69
- // Divided by the days in the window, so a missing day counts as zero, not skipped.
70
- const days = Math.min(WINDOW, Math.round((times[i] - times[0]) / DAY) + 1);
71
- const row: Record<string, unknown> = { ...d };
72
- for (const k of yKeys) row[k] = sums[k] / days;
73
- return row as TData;
74
- });
75
- return { points, mode };
126
+ if (kind === "count") {
127
+ const window = mode === "rolling" ? WEEK : LONG_WINDOW;
128
+ const keep =
129
+ mode === "rolling" ? () => true : (i: number) => daysBetween(times[i], last) % WEEK === 0;
130
+ return { points: trailingMean(data, times, yKeys, window, keep), mode: "rolling", window };
76
131
  }
77
132
 
78
- // Weeks end on the last day, so only the oldest can be partial, and a partial week is dropped:
79
- // a 3-day sum would draw a dip that never happened.
80
- const last = times[times.length - 1];
133
+ // A level has no daily noise to average, and a mean draws slopes between its steps.
134
+ if (mode === "rolling") return { points: data, mode: "raw", window: 1 };
135
+
136
+ // Weeks end on the last day, so only the oldest can be partial, and a partial week is dropped.
81
137
  const buckets = new Map<number, TData[]>();
82
138
  data.forEach((d, i) => {
83
- const index = Math.floor(Math.round((last - times[i]) / DAY) / WINDOW);
139
+ const index = Math.floor(daysBetween(times[i], last) / WEEK);
84
140
  buckets.set(index, [...(buckets.get(index) ?? []), d]);
85
141
  });
86
142
  const oldest = Math.max(...buckets.keys());
87
- const oldestStart = last - (oldest * WINDOW + WINDOW - 1) * DAY;
143
+ const oldestStart = last - (oldest * WEEK + WEEK - 1) * DAY;
88
144
  if (times[0] > oldestStart && buckets.size > 1) buckets.delete(oldest);
89
145
 
90
146
  const points = [...buckets.entries()]
91
147
  .toSorted(([a], [b]) => b - a)
92
148
  .map(([index, rows]) => {
93
149
  const row: Record<string, unknown> = { ...rows[rows.length - 1] };
94
- row[xKey] = sameForm(data[0][xKey], last - (index * WINDOW + WINDOW - 1) * DAY);
150
+ row[xKey] = sameForm(data[0][xKey], last - (index * WEEK + WEEK - 1) * DAY);
95
151
  for (const k of yKeys) {
96
- row[k] =
97
- kind === "level"
98
- ? Number(rows[rows.length - 1][k] ?? 0)
99
- : rows.reduce((sum, r) => sum + Number(r[k] ?? 0), 0);
152
+ const readings = rows.map((r) => measured(r[k])).filter((v) => v !== null);
153
+ row[k] = readings.length > 0 ? readings[readings.length - 1] : null;
100
154
  }
101
155
  return row as TData;
102
156
  });
103
- return { points, mode };
157
+ return { points, mode, window: WEEK };
104
158
  }
105
159
 
106
160
  /** Labels at least `spacing` pixels apart, the way a time axis reads at any width. */
@@ -7,7 +7,8 @@
7
7
  import { curveMonotoneX } from "d3-shape";
8
8
  import Reveal from "./reveal.svelte";
9
9
  import {
10
- ROLLING_LABEL,
10
+ NO_DATA,
11
+ averageLabel,
11
12
  averageTo,
12
13
  smoothSeries,
13
14
  weekOf,
@@ -77,7 +78,7 @@
77
78
  active = d;
78
79
  });
79
80
  const x = formatX(d[xKey]);
80
- return mode === "weekly" ? weekOf(x) : mode === "rolling" ? averageTo(x) : x;
81
+ return mode === "weekly" ? weekOf(x) : mode === "rolling" ? averageTo(x, smoothed.window) : x;
81
82
  }
82
83
 
83
84
  // =========================
@@ -87,13 +88,14 @@
87
88
  const smoothed = $derived(
88
89
  smoothing
89
90
  ? smoothSeries(input, xKey, [yKey], smoothing)
90
- : { points: input, mode: "raw" as const },
91
+ : { points: input, mode: "raw" as const, window: 1 },
91
92
  );
92
93
  const data = $derived(smoothed.points);
93
94
  const mode = $derived(smoothed.mode);
94
- const valueLabel = $derived(mode === "rolling" ? ROLLING_LABEL : String(yKey));
95
+ const valueLabel = $derived(mode === "rolling" ? averageLabel(smoothed.window) : String(yKey));
95
96
  const curve = $derived(smooth ? curveMonotoneX : undefined);
96
- const last = $derived(data.length > 0 ? data[data.length - 1] : null);
97
+ // The newest measured point: a gap at the end has no height for the dot to sit at.
98
+ const last = $derived(data.findLast((d) => d[yKey] != null) ?? null);
97
99
  const display = $derived(active ?? last);
98
100
  const dot = $derived(endCap && !bare);
99
101
  // Bare runs to the edges: the fill reaches the bottom of the box and the line keeps three pixels of head so its stroke is not cut at the series
@@ -150,7 +152,10 @@
150
152
  {#snippet children({ data: d })}
151
153
  <Tooltip.Header value={trackActive(d as TData)} />
152
154
  <Tooltip.List>
153
- <Tooltip.Item label={valueLabel} value={formatValue(Number(d[yKey]))} />
155
+ <Tooltip.Item
156
+ label={valueLabel}
157
+ value={d[yKey] == null ? NO_DATA : formatValue(Number(d[yKey]))}
158
+ />
154
159
  </Tooltip.List>
155
160
  {/snippet}
156
161
  </Tooltip.Root>
@@ -0,0 +1,265 @@
1
+ import { untrack } from "svelte";
2
+ import { SvelteSet } from "svelte/reactivity";
3
+ import { prefersReducedMotion } from "svelte/motion";
4
+ import { cubicOut } from "svelte/easing";
5
+ import { durationMs } from "../lib/motion.js";
6
+ import { createKanbanDnd } from "./use-kanban-dnd.svelte.js";
7
+ import { createKanbanRailState } from "./kanban-rail-state.svelte.js";
8
+ import { createCardKeyDown } from "./kanban-keyboard.js";
9
+ import type {
10
+ KanbanColumn as KanbanColumnDef,
11
+ KanbanColumnContext,
12
+ KanbanMove,
13
+ KanbanSortKey,
14
+ } from "./kanban-types.js";
15
+
16
+ /** The board props the state reads, with the board's defaults already applied. */
17
+ export type KanbanBoardInput<T, C extends KanbanColumnDef> = {
18
+ columns: C[];
19
+ items: T[];
20
+ getId: (item: T) => string;
21
+ getColumn: (item: T) => string;
22
+ onMove: (id: string, move: KanbanMove) => void | Promise<void>;
23
+ boardOrder?: (item: T) => string | null | undefined;
24
+ manualTiebreak?: (a: T, b: T) => number;
25
+ sortKeys: KanbanSortKey<T>[];
26
+ sortKey: string;
27
+ onSelect?: (id: string) => void;
28
+ limit: number | ((columnId: string) => number | undefined);
29
+ expanded?: string[];
30
+ onExpandedChange?: (ids: string[]) => void;
31
+ hidden?: string[];
32
+ onHiddenChange?: (ids: string[]) => void;
33
+ dropAtTop?: (columnId: string) => boolean;
34
+ isDroppable?: (columnId: string) => boolean;
35
+ };
36
+
37
+ /** One column as the board renders it: every card, the head over the fold and the tail in it. */
38
+ export type KanbanColumnView<T, C extends KanbanColumnDef> = {
39
+ column: C;
40
+ all: T[];
41
+ head: T[];
42
+ tail: T[];
43
+ foldable: boolean;
44
+ open: boolean;
45
+ capped: boolean;
46
+ };
47
+
48
+ /**
49
+ * A board's state: sort, the per-column fold and cap, and the drag wiring. The hidden rail
50
+ * lives in `rail`, the keyboard move in `onCardKeyDown`.
51
+ * Call it during component init, since it registers an effect. `input` is read on every use.
52
+ */
53
+ export function createKanbanBoardState<T, C extends KanbanColumnDef>(
54
+ input: () => KanbanBoardInput<T, C>,
55
+ ) {
56
+ // The drop slot takes the held card's height, so the column doesn't jump when it lands.
57
+ let slotHeight = $state(0);
58
+ let slotMotion = $derived({
59
+ duration: prefersReducedMotion.current ? 0 : durationMs("quick"),
60
+ easing: cubicOut,
61
+ });
62
+
63
+ let internalExpanded = $state<string[]>([]);
64
+ let expandedIds = $derived(input().expanded ?? internalExpanded);
65
+
66
+ function compare(a: T, b: T): number {
67
+ const { sortKey, manualTiebreak, boardOrder, sortKeys } = input();
68
+ if (sortKey === "manual") {
69
+ const tie = manualTiebreak?.(a, b) ?? 0;
70
+ if (!boardOrder) return tie;
71
+ const ao = boardOrder(a);
72
+ const bo = boardOrder(b);
73
+ if (ao == null && bo == null) return tie;
74
+ if (ao == null) return 1;
75
+ if (bo == null) return -1;
76
+ return ao < bo ? -1 : ao > bo ? 1 : tie;
77
+ }
78
+ const key = sortKeys.find((entry) => entry.id === sortKey);
79
+ return key ? key.compare(a, b) : 0;
80
+ }
81
+
82
+ function sortedByColumn(columnId: string): T[] {
83
+ const { items, getColumn } = input();
84
+ return items
85
+ .filter((item) => getColumn(item) === columnId)
86
+ .slice()
87
+ .sort(compare);
88
+ }
89
+
90
+ function idAt(list: T[], index: number): string | null {
91
+ const item = list[index];
92
+ return item === undefined ? null : input().getId(item);
93
+ }
94
+
95
+ function capOf(columnId: string): number | undefined {
96
+ const { limit } = input();
97
+ return typeof limit === "function" ? limit(columnId) : limit;
98
+ }
99
+
100
+ /** The column holds more cards than its cap, so it gets a fold. */
101
+ function isFoldable(columnId: string, total: number): boolean {
102
+ const cap = capOf(columnId);
103
+ return cap !== undefined && total > cap;
104
+ }
105
+
106
+ // Open from this board's "Show N more", on top of the header caret's `expanded`.
107
+ const laneOpen = new SvelteSet<string>();
108
+
109
+ function isOpen(columnId: string): boolean {
110
+ return expandedIds.includes(columnId) || laneOpen.has(columnId);
111
+ }
112
+
113
+ /** The fold is shut, so the tail is out of sight. */
114
+ function isCapped(columnId: string, total: number): boolean {
115
+ return isFoldable(columnId, total) && !isOpen(columnId);
116
+ }
117
+
118
+ // A card moved into a folded column stays in view, wherever its sort puts it.
119
+ const pinned = new SvelteSet<string>();
120
+
121
+ // The caret folding a column shuts this board's own fold of it too, so every lane lands on the cap.
122
+ let previousExpanded: string[] = [];
123
+ $effect(() => {
124
+ const now = expandedIds;
125
+ untrack(() => {
126
+ for (const id of previousExpanded) if (!now.includes(id)) shut(id);
127
+ });
128
+ previousExpanded = [...now];
129
+ });
130
+
131
+ function moveAndPin(id: string, move: KanbanMove): void | Promise<void> {
132
+ pinned.add(id);
133
+ return input().onMove(id, move);
134
+ }
135
+
136
+ function canDrop(columnId: string): boolean {
137
+ const { isDroppable, columns } = input();
138
+ return isDroppable?.(columnId) ?? columns.find((c) => c.id === columnId)?.droppable ?? true;
139
+ }
140
+
141
+ function setExpanded(next: string[]): void {
142
+ internalExpanded = next;
143
+ input().onExpandedChange?.(next);
144
+ }
145
+
146
+ // Shutting drops the column's pins, so the fold closes to exactly its cap.
147
+ function shut(columnId: string): void {
148
+ laneOpen.delete(columnId);
149
+ for (const item of sortedByColumn(columnId)) pinned.delete(input().getId(item));
150
+ }
151
+
152
+ /** "Show N more": opens this board's cell only. */
153
+ function expand(columnId: string): void {
154
+ laneOpen.add(columnId);
155
+ }
156
+
157
+ /** "Show less": shuts this board's cell, and lifts the caret's open if it was the caret's. */
158
+ function collapse(columnId: string): void {
159
+ shut(columnId);
160
+ if (expandedIds.includes(columnId)) setExpanded(expandedIds.filter((id) => id !== columnId));
161
+ }
162
+
163
+ /** The header caret: the whole column, in every board that shares `expanded`. */
164
+ function toggleColumn(columnId: string): void {
165
+ if (expandedIds.includes(columnId)) collapse(columnId);
166
+ else setExpanded([...expandedIds, columnId]);
167
+ }
168
+
169
+ const dnd = createKanbanDnd<T>({
170
+ items: () => input().items,
171
+ getId: (item) => input().getId(item),
172
+ getColumn: (item) => input().getColumn(item),
173
+ getColumnItems: sortedByColumn,
174
+ getSortKey: () => input().sortKey,
175
+ boardOrder: (item) => input().boardOrder?.(item),
176
+ onMove: moveAndPin,
177
+ dropAtTop: (columnId) =>
178
+ input().dropAtTop?.(columnId) ?? isCapped(columnId, sortedByColumn(columnId).length),
179
+ isDroppable: canDrop,
180
+ });
181
+
182
+ const rail = createKanbanRailState({
183
+ hidden: () => input().hidden,
184
+ onHiddenChange: () => input().onHiddenChange,
185
+ canDrop,
186
+ dnd,
187
+ });
188
+
189
+ const onCardKeyDown = createCardKeyDown<T, C>({
190
+ input,
191
+ canDrop,
192
+ isHidden: (columnId) => rail.hiddenSet.has(columnId),
193
+ sortedByColumn,
194
+ isCapped,
195
+ moveAndPin,
196
+ });
197
+
198
+ let view = $derived(
199
+ input().columns.map((column): KanbanColumnView<T, C> => {
200
+ const all = sortedByColumn(column.id);
201
+ const cap = capOf(column.id);
202
+ const foldable = isFoldable(column.id, all.length);
203
+ const open = foldable && isOpen(column.id);
204
+ if (!foldable || cap === undefined) {
205
+ return { column, all, head: all, tail: [] as T[], foldable, open, capped: false };
206
+ }
207
+ // Head: the first `cap` cards plus any pinned ones, in sort order. The fold holds the rest.
208
+ const head: T[] = [];
209
+ const tail: T[] = [];
210
+ all.forEach((item, index) => {
211
+ (index < cap || pinned.has(input().getId(item)) ? head : tail).push(item);
212
+ });
213
+ return { column, all, head, tail, foldable, open, capped: !open && tail.length > 0 };
214
+ }),
215
+ );
216
+
217
+ let hiddenColumns = $derived(input().columns.filter((column) => rail.hiddenSet.has(column.id)));
218
+
219
+ function contextOf(entry: KanbanColumnView<T, C>, active: boolean): KanbanColumnContext {
220
+ const { column, all, head, capped } = entry;
221
+ return {
222
+ count: all.length,
223
+ active,
224
+ shown: capped ? head.length : all.length,
225
+ capped,
226
+ hide: () => rail.hide(column.id),
227
+ expand: () => expand(column.id),
228
+ collapse: () => collapse(column.id),
229
+ };
230
+ }
231
+
232
+ return {
233
+ get slotHeight() {
234
+ return slotHeight;
235
+ },
236
+ set slotHeight(value: number) {
237
+ slotHeight = value;
238
+ },
239
+ get slotMotion() {
240
+ return slotMotion;
241
+ },
242
+ get expandedIds() {
243
+ return expandedIds;
244
+ },
245
+ get view() {
246
+ return view;
247
+ },
248
+ get hiddenColumns() {
249
+ return hiddenColumns;
250
+ },
251
+ dnd,
252
+ rail,
253
+ sortedByColumn,
254
+ idAt,
255
+ expand,
256
+ collapse,
257
+ toggleColumn,
258
+ onCardKeyDown,
259
+ contextOf,
260
+ };
261
+ }
262
+
263
+ export type KanbanBoardState<T, C extends KanbanColumnDef> = ReturnType<
264
+ typeof createKanbanBoardState<T, C>
265
+ >;