@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.
- package/dist/charts/smoothing.js +78 -38
- package/dist/work/kanban-board-state.svelte.js +205 -0
- package/dist/work/kanban-keyboard.js +45 -0
- package/dist/work/kanban-rail-state.svelte.js +85 -0
- package/package.json +1 -1
- package/src/charts/area-chart.svelte +15 -7
- package/src/charts/index.ts +1 -0
- package/src/charts/smoothing.test.ts +82 -0
- package/src/charts/smoothing.ts +91 -37
- package/src/charts/sparkline.svelte +11 -6
- package/src/work/kanban-board-state.svelte.ts +265 -0
- package/src/work/kanban-board.svelte +68 -415
- package/src/work/kanban-column-head.svelte +65 -0
- package/src/work/kanban-column.svelte +47 -16
- package/src/work/kanban-fold-chevron.svelte +34 -0
- package/src/work/kanban-hidden-rail.svelte +110 -0
- package/src/work/kanban-keyboard.ts +62 -0
- package/src/work/kanban-rail-state.svelte.ts +105 -0
package/dist/charts/smoothing.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
/** The tooltip's name for a drawn point, beside its date. */
|
|
2
|
-
export const
|
|
2
|
+
export const averageLabel = (days) => `${days}-day average`;
|
|
3
|
+
export const ROLLING_LABEL = averageLabel(7);
|
|
3
4
|
export const weekOf = (start) => `Week of ${start}`;
|
|
4
|
-
export const averageTo = (end) => `${
|
|
5
|
+
export const averageTo = (end, days = 7) => `${averageLabel(days)} to ${end}`;
|
|
6
|
+
/** What a point the collector never measured reads as, where a number would claim a 0. */
|
|
7
|
+
export const NO_DATA = "No data";
|
|
5
8
|
const DAY = 864e5;
|
|
6
|
-
const
|
|
9
|
+
const WEEK = 7;
|
|
10
|
+
/** A year at 7 days still drew one-day spikes as humps; 28 days keeps the growth and drops the spikes. */
|
|
11
|
+
const LONG_WINDOW = 28;
|
|
7
12
|
// =========================
|
|
8
13
|
// FUNCTIONS
|
|
9
14
|
// =========================
|
|
10
|
-
/** About 30 to 60 marks across a chart: raw to a
|
|
15
|
+
/** About 30 to 60 marks across a chart: raw to a fortnight, a daily average to a quarter, weekly past it. */
|
|
11
16
|
export function smoothMode(rangeDays) {
|
|
12
17
|
if (rangeDays <= 14) return "raw";
|
|
13
18
|
if (rangeDays <= 120) return "rolling";
|
|
@@ -16,70 +21,105 @@ export function smoothMode(rangeDays) {
|
|
|
16
21
|
function timeOf(value) {
|
|
17
22
|
return value instanceof Date ? value.getTime() : new Date(String(value)).getTime();
|
|
18
23
|
}
|
|
24
|
+
/** Null, undefined or not a number: a day nobody measured. An absent row still counts as 0. */
|
|
25
|
+
function measured(value) {
|
|
26
|
+
if (value == null) return null;
|
|
27
|
+
const n = Number(value);
|
|
28
|
+
return Number.isFinite(n) ? n : null;
|
|
29
|
+
}
|
|
19
30
|
/** The bucket's start in the same form the series uses, so the axis and the formatter see no change. */
|
|
20
31
|
function sameForm(sample, time) {
|
|
21
32
|
if (sample instanceof Date) return new Date(time);
|
|
22
33
|
const iso = new Date(time).toISOString();
|
|
23
34
|
return String(sample).length === 10 ? iso.slice(0, 10) : iso;
|
|
24
35
|
}
|
|
36
|
+
const daysBetween = (from, to) => Math.round((to - from) / DAY);
|
|
37
|
+
/**
|
|
38
|
+
* A trailing mean over `window` days, drawn on the days `keep` picks.
|
|
39
|
+
*
|
|
40
|
+
* A missing value is skipped, never averaged in as 0, and a window with fewer measured days than
|
|
41
|
+
* missing ones draws a gap: one partial day after a 12-day outage is not a week. Days before a
|
|
42
|
+
* series' first measured value are not tracked yet, so they neither draw nor count as missing.
|
|
43
|
+
*/
|
|
44
|
+
function trailingMean(data, times, yKeys, window, keep) {
|
|
45
|
+
const firstSeen = Object.fromEntries(yKeys.map((k) => [k, times[data.findIndex((d) => measured(d[k]) !== null)] ?? Infinity]));
|
|
46
|
+
const out = [];
|
|
47
|
+
data.forEach((d, i) => {
|
|
48
|
+
if (!keep(i)) return;
|
|
49
|
+
const row = { ...d };
|
|
50
|
+
for (const k of yKeys) {
|
|
51
|
+
if (times[i] < firstSeen[k]) {
|
|
52
|
+
row[k] = null;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
let sum = 0;
|
|
56
|
+
let missing = 0;
|
|
57
|
+
for (let j = i; j >= 0 && times[i] - times[j] < window * DAY; j--) {
|
|
58
|
+
const value = measured(data[j][k]);
|
|
59
|
+
if (value === null) missing += times[j] >= firstSeen[k] ? 1 : 0;
|
|
60
|
+
else sum += value;
|
|
61
|
+
}
|
|
62
|
+
// Divided by the tracked days in the window, so a row absent from a sparse series counts as 0.
|
|
63
|
+
const days = Math.min(window, daysBetween(Math.max(times[0], firstSeen[k]), times[i]) + 1);
|
|
64
|
+
const seen = days - missing;
|
|
65
|
+
row[k] = seen > 0 && seen * 2 >= days ? sum / seen : null;
|
|
66
|
+
}
|
|
67
|
+
out.push(row);
|
|
68
|
+
});
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
25
71
|
/**
|
|
26
|
-
* The line a chart draws
|
|
27
|
-
*
|
|
72
|
+
* The line a chart draws. Up to a quarter, a trailing 7-day mean per day. Past it, a trailing
|
|
73
|
+
* 28-day mean read once a week from the last day, so the axis stays in per-day units at every
|
|
74
|
+
* range; a level reads its last value each week instead. A missing value (null) is a gap in the
|
|
75
|
+
* line, never a 0. Totals stay the caller's raw sums; this only moves the drawn points.
|
|
28
76
|
*/
|
|
29
77
|
export function smoothSeries(data, xKey, yKeys, { rangeDays, kind = "count" }) {
|
|
30
78
|
const mode = smoothMode(rangeDays);
|
|
31
|
-
if (mode === "raw" || data.length <
|
|
79
|
+
if (mode === "raw" || data.length < WEEK) return {
|
|
32
80
|
points: data,
|
|
33
|
-
mode: "raw"
|
|
81
|
+
mode: "raw",
|
|
82
|
+
window: 1
|
|
34
83
|
};
|
|
35
84
|
const times = data.map((d) => timeOf(d[xKey]));
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
mode: "raw"
|
|
41
|
-
};
|
|
42
|
-
let from = 0;
|
|
43
|
-
const sums = Object.fromEntries(yKeys.map((k) => [k, 0]));
|
|
44
|
-
const points = data.map((d, i) => {
|
|
45
|
-
for (const k of yKeys) sums[k] += Number(d[k] ?? 0);
|
|
46
|
-
while (times[i] - times[from] >= WINDOW * DAY) {
|
|
47
|
-
for (const k of yKeys) sums[k] -= Number(data[from][k] ?? 0);
|
|
48
|
-
from += 1;
|
|
49
|
-
}
|
|
50
|
-
// Divided by the days in the window, so a missing day counts as zero, not skipped.
|
|
51
|
-
const days = Math.min(WINDOW, Math.round((times[i] - times[0]) / DAY) + 1);
|
|
52
|
-
const row = { ...d };
|
|
53
|
-
for (const k of yKeys) row[k] = sums[k] / days;
|
|
54
|
-
return row;
|
|
55
|
-
});
|
|
85
|
+
const last = times[times.length - 1];
|
|
86
|
+
if (kind === "count") {
|
|
87
|
+
const window = mode === "rolling" ? WEEK : LONG_WINDOW;
|
|
88
|
+
const keep = mode === "rolling" ? () => true : (i) => daysBetween(times[i], last) % WEEK === 0;
|
|
56
89
|
return {
|
|
57
|
-
points,
|
|
58
|
-
mode
|
|
90
|
+
points: trailingMean(data, times, yKeys, window, keep),
|
|
91
|
+
mode: "rolling",
|
|
92
|
+
window
|
|
59
93
|
};
|
|
60
94
|
}
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
95
|
+
// A level has no daily noise to average, and a mean draws slopes between its steps.
|
|
96
|
+
if (mode === "rolling") return {
|
|
97
|
+
points: data,
|
|
98
|
+
mode: "raw",
|
|
99
|
+
window: 1
|
|
100
|
+
};
|
|
101
|
+
// Weeks end on the last day, so only the oldest can be partial, and a partial week is dropped.
|
|
64
102
|
const buckets = new Map();
|
|
65
103
|
data.forEach((d, i) => {
|
|
66
|
-
const index = Math.floor(
|
|
104
|
+
const index = Math.floor(daysBetween(times[i], last) / WEEK);
|
|
67
105
|
buckets.set(index, [...buckets.get(index) ?? [], d]);
|
|
68
106
|
});
|
|
69
107
|
const oldest = Math.max(...buckets.keys());
|
|
70
|
-
const oldestStart = last - (oldest *
|
|
108
|
+
const oldestStart = last - (oldest * WEEK + WEEK - 1) * DAY;
|
|
71
109
|
if (times[0] > oldestStart && buckets.size > 1) buckets.delete(oldest);
|
|
72
110
|
const points = [...buckets.entries()].toSorted(([a], [b]) => b - a).map(([index, rows]) => {
|
|
73
111
|
const row = { ...rows[rows.length - 1] };
|
|
74
|
-
row[xKey] = sameForm(data[0][xKey], last - (index *
|
|
112
|
+
row[xKey] = sameForm(data[0][xKey], last - (index * WEEK + WEEK - 1) * DAY);
|
|
75
113
|
for (const k of yKeys) {
|
|
76
|
-
|
|
114
|
+
const readings = rows.map((r) => measured(r[k])).filter((v) => v !== null);
|
|
115
|
+
row[k] = readings.length > 0 ? readings[readings.length - 1] : null;
|
|
77
116
|
}
|
|
78
117
|
return row;
|
|
79
118
|
});
|
|
80
119
|
return {
|
|
81
120
|
points,
|
|
82
|
-
mode
|
|
121
|
+
mode,
|
|
122
|
+
window: WEEK
|
|
83
123
|
};
|
|
84
124
|
}
|
|
85
125
|
/** Labels at least `spacing` pixels apart, the way a time axis reads at any width. */
|
|
@@ -0,0 +1,205 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* A board's state: sort, the per-column fold and cap, and the drag wiring. The hidden rail
|
|
11
|
+
* lives in `rail`, the keyboard move in `onCardKeyDown`.
|
|
12
|
+
* Call it during component init, since it registers an effect. `input` is read on every use.
|
|
13
|
+
*/
|
|
14
|
+
export function createKanbanBoardState(input) {
|
|
15
|
+
// The drop slot takes the held card's height, so the column doesn't jump when it lands.
|
|
16
|
+
let slotHeight = $state(0);
|
|
17
|
+
let slotMotion = $derived({
|
|
18
|
+
duration: prefersReducedMotion.current ? 0 : durationMs("quick"),
|
|
19
|
+
easing: cubicOut
|
|
20
|
+
});
|
|
21
|
+
let internalExpanded = $state([]);
|
|
22
|
+
let expandedIds = $derived(input().expanded ?? internalExpanded);
|
|
23
|
+
function compare(a, b) {
|
|
24
|
+
const { sortKey, manualTiebreak, boardOrder, sortKeys } = input();
|
|
25
|
+
if (sortKey === "manual") {
|
|
26
|
+
const tie = manualTiebreak?.(a, b) ?? 0;
|
|
27
|
+
if (!boardOrder) return tie;
|
|
28
|
+
const ao = boardOrder(a);
|
|
29
|
+
const bo = boardOrder(b);
|
|
30
|
+
if (ao == null && bo == null) return tie;
|
|
31
|
+
if (ao == null) return 1;
|
|
32
|
+
if (bo == null) return -1;
|
|
33
|
+
return ao < bo ? -1 : ao > bo ? 1 : tie;
|
|
34
|
+
}
|
|
35
|
+
const key = sortKeys.find((entry) => entry.id === sortKey);
|
|
36
|
+
return key ? key.compare(a, b) : 0;
|
|
37
|
+
}
|
|
38
|
+
function sortedByColumn(columnId) {
|
|
39
|
+
const { items, getColumn } = input();
|
|
40
|
+
return items.filter((item) => getColumn(item) === columnId).slice().sort(compare);
|
|
41
|
+
}
|
|
42
|
+
function idAt(list, index) {
|
|
43
|
+
const item = list[index];
|
|
44
|
+
return item === undefined ? null : input().getId(item);
|
|
45
|
+
}
|
|
46
|
+
function capOf(columnId) {
|
|
47
|
+
const { limit } = input();
|
|
48
|
+
return typeof limit === "function" ? limit(columnId) : limit;
|
|
49
|
+
}
|
|
50
|
+
/** The column holds more cards than its cap, so it gets a fold. */
|
|
51
|
+
function isFoldable(columnId, total) {
|
|
52
|
+
const cap = capOf(columnId);
|
|
53
|
+
return cap !== undefined && total > cap;
|
|
54
|
+
}
|
|
55
|
+
// Open from this board's "Show N more", on top of the header caret's `expanded`.
|
|
56
|
+
const laneOpen = new SvelteSet();
|
|
57
|
+
function isOpen(columnId) {
|
|
58
|
+
return expandedIds.includes(columnId) || laneOpen.has(columnId);
|
|
59
|
+
}
|
|
60
|
+
/** The fold is shut, so the tail is out of sight. */
|
|
61
|
+
function isCapped(columnId, total) {
|
|
62
|
+
return isFoldable(columnId, total) && !isOpen(columnId);
|
|
63
|
+
}
|
|
64
|
+
// A card moved into a folded column stays in view, wherever its sort puts it.
|
|
65
|
+
const pinned = new SvelteSet();
|
|
66
|
+
// The caret folding a column shuts this board's own fold of it too, so every lane lands on the cap.
|
|
67
|
+
let previousExpanded = [];
|
|
68
|
+
$effect(() => {
|
|
69
|
+
const now = expandedIds;
|
|
70
|
+
untrack(() => {
|
|
71
|
+
for (const id of previousExpanded) if (!now.includes(id)) shut(id);
|
|
72
|
+
});
|
|
73
|
+
previousExpanded = [...now];
|
|
74
|
+
});
|
|
75
|
+
function moveAndPin(id, move) {
|
|
76
|
+
pinned.add(id);
|
|
77
|
+
return input().onMove(id, move);
|
|
78
|
+
}
|
|
79
|
+
function canDrop(columnId) {
|
|
80
|
+
const { isDroppable, columns } = input();
|
|
81
|
+
return isDroppable?.(columnId) ?? columns.find((c) => c.id === columnId)?.droppable ?? true;
|
|
82
|
+
}
|
|
83
|
+
function setExpanded(next) {
|
|
84
|
+
internalExpanded = next;
|
|
85
|
+
input().onExpandedChange?.(next);
|
|
86
|
+
}
|
|
87
|
+
// Shutting drops the column's pins, so the fold closes to exactly its cap.
|
|
88
|
+
function shut(columnId) {
|
|
89
|
+
laneOpen.delete(columnId);
|
|
90
|
+
for (const item of sortedByColumn(columnId)) pinned.delete(input().getId(item));
|
|
91
|
+
}
|
|
92
|
+
/** "Show N more": opens this board's cell only. */
|
|
93
|
+
function expand(columnId) {
|
|
94
|
+
laneOpen.add(columnId);
|
|
95
|
+
}
|
|
96
|
+
/** "Show less": shuts this board's cell, and lifts the caret's open if it was the caret's. */
|
|
97
|
+
function collapse(columnId) {
|
|
98
|
+
shut(columnId);
|
|
99
|
+
if (expandedIds.includes(columnId)) setExpanded(expandedIds.filter((id) => id !== columnId));
|
|
100
|
+
}
|
|
101
|
+
/** The header caret: the whole column, in every board that shares `expanded`. */
|
|
102
|
+
function toggleColumn(columnId) {
|
|
103
|
+
if (expandedIds.includes(columnId)) collapse(columnId);
|
|
104
|
+
else setExpanded([...expandedIds, columnId]);
|
|
105
|
+
}
|
|
106
|
+
const dnd = createKanbanDnd({
|
|
107
|
+
items: () => input().items,
|
|
108
|
+
getId: (item) => input().getId(item),
|
|
109
|
+
getColumn: (item) => input().getColumn(item),
|
|
110
|
+
getColumnItems: sortedByColumn,
|
|
111
|
+
getSortKey: () => input().sortKey,
|
|
112
|
+
boardOrder: (item) => input().boardOrder?.(item),
|
|
113
|
+
onMove: moveAndPin,
|
|
114
|
+
dropAtTop: (columnId) => input().dropAtTop?.(columnId) ?? isCapped(columnId, sortedByColumn(columnId).length),
|
|
115
|
+
isDroppable: canDrop
|
|
116
|
+
});
|
|
117
|
+
const rail = createKanbanRailState({
|
|
118
|
+
hidden: () => input().hidden,
|
|
119
|
+
onHiddenChange: () => input().onHiddenChange,
|
|
120
|
+
canDrop,
|
|
121
|
+
dnd
|
|
122
|
+
});
|
|
123
|
+
const onCardKeyDown = createCardKeyDown({
|
|
124
|
+
input,
|
|
125
|
+
canDrop,
|
|
126
|
+
isHidden: (columnId) => rail.hiddenSet.has(columnId),
|
|
127
|
+
sortedByColumn,
|
|
128
|
+
isCapped,
|
|
129
|
+
moveAndPin
|
|
130
|
+
});
|
|
131
|
+
let view = $derived(input().columns.map((column) => {
|
|
132
|
+
const all = sortedByColumn(column.id);
|
|
133
|
+
const cap = capOf(column.id);
|
|
134
|
+
const foldable = isFoldable(column.id, all.length);
|
|
135
|
+
const open = foldable && isOpen(column.id);
|
|
136
|
+
if (!foldable || cap === undefined) {
|
|
137
|
+
return {
|
|
138
|
+
column,
|
|
139
|
+
all,
|
|
140
|
+
head: all,
|
|
141
|
+
tail: [],
|
|
142
|
+
foldable,
|
|
143
|
+
open,
|
|
144
|
+
capped: false
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// Head: the first `cap` cards plus any pinned ones, in sort order. The fold holds the rest.
|
|
148
|
+
const head = [];
|
|
149
|
+
const tail = [];
|
|
150
|
+
all.forEach((item, index) => {
|
|
151
|
+
(index < cap || pinned.has(input().getId(item)) ? head : tail).push(item);
|
|
152
|
+
});
|
|
153
|
+
return {
|
|
154
|
+
column,
|
|
155
|
+
all,
|
|
156
|
+
head,
|
|
157
|
+
tail,
|
|
158
|
+
foldable,
|
|
159
|
+
open,
|
|
160
|
+
capped: !open && tail.length > 0
|
|
161
|
+
};
|
|
162
|
+
}));
|
|
163
|
+
let hiddenColumns = $derived(input().columns.filter((column) => rail.hiddenSet.has(column.id)));
|
|
164
|
+
function contextOf(entry, active) {
|
|
165
|
+
const { column, all, head, capped } = entry;
|
|
166
|
+
return {
|
|
167
|
+
count: all.length,
|
|
168
|
+
active,
|
|
169
|
+
shown: capped ? head.length : all.length,
|
|
170
|
+
capped,
|
|
171
|
+
hide: () => rail.hide(column.id),
|
|
172
|
+
expand: () => expand(column.id),
|
|
173
|
+
collapse: () => collapse(column.id)
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
get slotHeight() {
|
|
178
|
+
return slotHeight;
|
|
179
|
+
},
|
|
180
|
+
set slotHeight(value) {
|
|
181
|
+
slotHeight = value;
|
|
182
|
+
},
|
|
183
|
+
get slotMotion() {
|
|
184
|
+
return slotMotion;
|
|
185
|
+
},
|
|
186
|
+
get expandedIds() {
|
|
187
|
+
return expandedIds;
|
|
188
|
+
},
|
|
189
|
+
get view() {
|
|
190
|
+
return view;
|
|
191
|
+
},
|
|
192
|
+
get hiddenColumns() {
|
|
193
|
+
return hiddenColumns;
|
|
194
|
+
},
|
|
195
|
+
dnd,
|
|
196
|
+
rail,
|
|
197
|
+
sortedByColumn,
|
|
198
|
+
idAt,
|
|
199
|
+
expand,
|
|
200
|
+
collapse,
|
|
201
|
+
toggleColumn,
|
|
202
|
+
onCardKeyDown,
|
|
203
|
+
contextOf
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { boardOrderAt } from "./board-order.js";
|
|
2
|
+
/** The card's keydown handler: Enter or Space selects, Alt + arrow moves. */
|
|
3
|
+
export function createCardKeyDown(config) {
|
|
4
|
+
const { input, canDrop, isHidden, sortedByColumn, isCapped, moveAndPin } = config;
|
|
5
|
+
/**
|
|
6
|
+
* Alt + ArrowLeft/Right moves the focused card to the next droppable column, so
|
|
7
|
+
* keyboard and screen-reader users get drag's onMove contract.
|
|
8
|
+
*/
|
|
9
|
+
return function onCardKeyDown(e, id) {
|
|
10
|
+
const { onSelect, items, getId, getColumn, columns, sortKey, dropAtTop, boardOrder } = input();
|
|
11
|
+
if (onSelect && e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) {
|
|
12
|
+
e.preventDefault();
|
|
13
|
+
onSelect(id);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (!e.altKey || e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
|
17
|
+
const item = items.find((entry) => getId(entry) === id);
|
|
18
|
+
if (!item) return;
|
|
19
|
+
const current = getColumn(item);
|
|
20
|
+
const at = columns.findIndex((c) => c.id === current);
|
|
21
|
+
if (at === -1) return;
|
|
22
|
+
const dir = e.key === "ArrowRight" ? 1 : -1;
|
|
23
|
+
for (let i = at + dir; i >= 0 && i < columns.length; i += dir) {
|
|
24
|
+
const target = columns[i];
|
|
25
|
+
if (!canDrop(target.id) || isHidden(target.id) || target.id === current) continue;
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
if (sortKey === "manual") {
|
|
28
|
+
const column = sortedByColumn(target.id).filter((entry) => getId(entry) !== id);
|
|
29
|
+
const atTop = dropAtTop?.(target.id) ?? isCapped(target.id, column.length + 1);
|
|
30
|
+
const index = atTop ? 0 : column.length;
|
|
31
|
+
const before = column[index - 1];
|
|
32
|
+
const after = column[index];
|
|
33
|
+
void moveAndPin(id, {
|
|
34
|
+
columnId: target.id,
|
|
35
|
+
...boardOrder ? { boardOrder: boardOrderAt(column, index, (entry) => boardOrder(entry)) } : {},
|
|
36
|
+
beforeId: before === undefined ? null : getId(before),
|
|
37
|
+
afterId: after === undefined ? null : getId(after)
|
|
38
|
+
});
|
|
39
|
+
} else {
|
|
40
|
+
void moveAndPin(id, { columnId: target.id });
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { SvelteSet } from "svelte/reactivity";
|
|
2
|
+
/** The rail of hidden columns: which are folded, the fold and unfold timing, and drops onto it. */
|
|
3
|
+
export function createKanbanRailState(config) {
|
|
4
|
+
const { canDrop, dnd } = config;
|
|
5
|
+
let internalHidden = $state([]);
|
|
6
|
+
let hiddenIds = $derived(config.hidden() ?? internalHidden);
|
|
7
|
+
let hiddenSet = $derived(new Set(hiddenIds));
|
|
8
|
+
let railOpen = $state(true);
|
|
9
|
+
let dragOverHidden = $state(null);
|
|
10
|
+
const collapsing = new SvelteSet();
|
|
11
|
+
const expanding = new SvelteSet();
|
|
12
|
+
const leaving = new SvelteSet();
|
|
13
|
+
function setHidden(next) {
|
|
14
|
+
internalHidden = next;
|
|
15
|
+
config.onHiddenChange()?.(next);
|
|
16
|
+
}
|
|
17
|
+
// Fold: the column fades out first, then leaves the row and joins the rail.
|
|
18
|
+
function hide(columnId) {
|
|
19
|
+
if (hiddenSet.has(columnId) || collapsing.has(columnId)) return;
|
|
20
|
+
collapsing.add(columnId);
|
|
21
|
+
setTimeout(() => {
|
|
22
|
+
setHidden([...hiddenIds, columnId]);
|
|
23
|
+
collapsing.delete(columnId);
|
|
24
|
+
}, 300);
|
|
25
|
+
}
|
|
26
|
+
// Unfold: the rail entry leaves, then the column grows back in.
|
|
27
|
+
function show(columnId) {
|
|
28
|
+
leaving.add(columnId);
|
|
29
|
+
setTimeout(() => {
|
|
30
|
+
leaving.delete(columnId);
|
|
31
|
+
expanding.add(columnId);
|
|
32
|
+
setHidden(hiddenIds.filter((id) => id !== columnId));
|
|
33
|
+
setTimeout(() => expanding.delete(columnId), 350);
|
|
34
|
+
}, 200);
|
|
35
|
+
}
|
|
36
|
+
function showAll() {
|
|
37
|
+
for (const id of hiddenIds) leaving.add(id);
|
|
38
|
+
setTimeout(() => {
|
|
39
|
+
leaving.clear();
|
|
40
|
+
setHidden([]);
|
|
41
|
+
}, 200);
|
|
42
|
+
}
|
|
43
|
+
function onHiddenDragOver(e, columnId) {
|
|
44
|
+
if (!canDrop(columnId)) return;
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
|
47
|
+
if (dragOverHidden !== columnId) dragOverHidden = columnId;
|
|
48
|
+
dnd.clearDragOver();
|
|
49
|
+
}
|
|
50
|
+
function onHiddenDragLeave(e) {
|
|
51
|
+
if (!e.currentTarget.contains(e.relatedTarget)) dragOverHidden = null;
|
|
52
|
+
}
|
|
53
|
+
// A card dropped on a folded column brings the column back and lands in it.
|
|
54
|
+
function onHiddenDrop(e, columnId) {
|
|
55
|
+
dragOverHidden = null;
|
|
56
|
+
dnd.onDrop(e, columnId);
|
|
57
|
+
show(columnId);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
get hiddenSet() {
|
|
61
|
+
return hiddenSet;
|
|
62
|
+
},
|
|
63
|
+
get railOpen() {
|
|
64
|
+
return railOpen;
|
|
65
|
+
},
|
|
66
|
+
set railOpen(value) {
|
|
67
|
+
railOpen = value;
|
|
68
|
+
},
|
|
69
|
+
get dragOverHidden() {
|
|
70
|
+
return dragOverHidden;
|
|
71
|
+
},
|
|
72
|
+
set dragOverHidden(value) {
|
|
73
|
+
dragOverHidden = value;
|
|
74
|
+
},
|
|
75
|
+
collapsing,
|
|
76
|
+
expanding,
|
|
77
|
+
leaving,
|
|
78
|
+
hide,
|
|
79
|
+
show,
|
|
80
|
+
showAll,
|
|
81
|
+
onHiddenDragOver,
|
|
82
|
+
onHiddenDragLeave,
|
|
83
|
+
onHiddenDrop
|
|
84
|
+
};
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyramzi/ui",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.15.0",
|
|
4
4
|
"description": "Svelte 5 UI for apps: primitives, effects and animations, plus work (tasks and docs), charts, slides, an editor, onboarding and a canvas as subpaths.",
|
|
5
5
|
"homepage": "https://ui.heyramzi.com",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
import Reveal from "./reveal.svelte";
|
|
15
15
|
import ReferenceLine, { type ChartReference } from "./reference-line.svelte";
|
|
16
16
|
import {
|
|
17
|
-
|
|
17
|
+
NO_DATA,
|
|
18
|
+
averageLabel as averageName,
|
|
18
19
|
averageTo,
|
|
19
20
|
smoothSeries,
|
|
20
21
|
weekOf,
|
|
@@ -97,10 +98,15 @@
|
|
|
97
98
|
return pointLabel(d);
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
/** What a drawn point is: a day, the week it opens, or the end of a 7-day average. */
|
|
101
|
+
/** What a drawn point is: a day, the week it opens, or the end of a 7 or 28-day average. */
|
|
101
102
|
function pointLabel(d: TData): string {
|
|
102
103
|
const x = formatX(d[xKey]);
|
|
103
|
-
return mode === "weekly" ? weekOf(x) : mode === "rolling" ? averageTo(x) : x;
|
|
104
|
+
return mode === "weekly" ? weekOf(x) : mode === "rolling" ? averageTo(x, smoothed.window) : x;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A missing point reads "No data", never the 0 Number(null) would claim. */
|
|
108
|
+
function shown(value: unknown): string {
|
|
109
|
+
return value == null ? NO_DATA : formatValue(Number(value));
|
|
104
110
|
}
|
|
105
111
|
|
|
106
112
|
// =========================
|
|
@@ -126,12 +132,12 @@
|
|
|
126
132
|
series.map((s) => s.key),
|
|
127
133
|
smoothing,
|
|
128
134
|
)
|
|
129
|
-
: { points: input, mode: "raw" as const },
|
|
135
|
+
: { points: input, mode: "raw" as const, window: 1 },
|
|
130
136
|
);
|
|
131
137
|
const data = $derived(smoothed.points);
|
|
132
138
|
const mode = $derived(smoothed.mode);
|
|
133
|
-
// The series label in the readout, unless the point is an average of the
|
|
134
|
-
const averageLabel = $derived(mode === "rolling" ?
|
|
139
|
+
// The series label in the readout, unless the point is an average of the days before it.
|
|
140
|
+
const averageLabel = $derived(mode === "rolling" ? averageName(smoothed.window) : null);
|
|
135
141
|
const dataMax = $derived.by(() => {
|
|
136
142
|
if (stacked) {
|
|
137
143
|
return Math.max(0, ...data.map((d) => series.reduce((s, k) => s + Number(d[k.key] ?? 0), 0)));
|
|
@@ -182,6 +188,8 @@
|
|
|
182
188
|
format={formatValue}
|
|
183
189
|
size="md"
|
|
184
190
|
/>
|
|
191
|
+
{:else if display && series.every((s) => display[s.key] == null)}
|
|
192
|
+
<Figure value={NO_DATA} eyebrow={pointLabel(display)} size="md" />
|
|
185
193
|
{:else if figureRows && display}
|
|
186
194
|
<Figure series={figureRows} eyebrow={pointLabel(display)} format={formatValue} size="md" />
|
|
187
195
|
{:else}
|
|
@@ -246,7 +254,7 @@
|
|
|
246
254
|
{#each series as s (s.key)}
|
|
247
255
|
<Tooltip.Item
|
|
248
256
|
label={averageLabel ?? s.label}
|
|
249
|
-
value={
|
|
257
|
+
value={shown(d[s.key])}
|
|
250
258
|
color={s.color}
|
|
251
259
|
/>
|
|
252
260
|
{/each}
|
package/src/charts/index.ts
CHANGED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import { averageTo, smoothSeries } from "./smoothing.js";
|
|
3
|
+
|
|
4
|
+
type Row = { date: string; value: number | null };
|
|
5
|
+
|
|
6
|
+
/** `values.length` days ending 2026-10-31, oldest first. */
|
|
7
|
+
function days(values: (number | null)[]): Row[] {
|
|
8
|
+
const end = Date.UTC(2026, 9, 31);
|
|
9
|
+
return values.map((value, i) => ({
|
|
10
|
+
date: new Date(end - (values.length - 1 - i) * 86_400_000).toISOString().slice(0, 10),
|
|
11
|
+
value,
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const smooth = (rows: Row[], rangeDays: number, kind: "count" | "level" = "count") =>
|
|
16
|
+
smoothSeries(rows, "date", ["value"], { rangeDays, kind });
|
|
17
|
+
|
|
18
|
+
describe("smoothSeries, a missing day", () => {
|
|
19
|
+
it("is left out of the 7-day average, not counted as 0", () => {
|
|
20
|
+
const rows = days([10, 10, 10, 10, null, 10, 10, 10, 10, 10]);
|
|
21
|
+
const values = smooth(rows, 90).points.map((p) => p.value);
|
|
22
|
+
expect(values.every((v) => v === 10)).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("draws a gap where most of the window was never measured", () => {
|
|
26
|
+
// A 12-day outage, then one partial day: the partial day alone is not a week's average.
|
|
27
|
+
const rows = days([100, 100, 100, 100, 100, 100, 100, ...Array(12).fill(null), 5, 100, 100]);
|
|
28
|
+
const values = smooth(rows, 90).points.map((p) => p.value);
|
|
29
|
+
expect(values.slice(12, 19)).toEqual(Array(7).fill(null));
|
|
30
|
+
expect(values.some((v) => v === 0)).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("before a series' first measured day is a gap that drags nothing down", () => {
|
|
34
|
+
const rows = days([null, null, null, null, null, 40, 40, 40, 40, 40]);
|
|
35
|
+
const values = smooth(rows, 90).points.map((p) => p.value);
|
|
36
|
+
expect(values.slice(0, 5)).toEqual([null, null, null, null, null]);
|
|
37
|
+
expect(values.slice(5)).toEqual([40, 40, 40, 40, 40]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("still counts a row absent from a sparse series as 0", () => {
|
|
41
|
+
const rows = days(Array(20).fill(7)).filter((_, i) => i % 2 === 0);
|
|
42
|
+
const tail = smooth(rows, 90).points.at(-1)?.value;
|
|
43
|
+
// Four sales of 7 in the 7 days to the last row: 28 over 7 days, not over the 4 rows.
|
|
44
|
+
expect(tail).toBe(4);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("is a gap in a level's weekly reading, never a 0", () => {
|
|
48
|
+
const rows = days([...Array(14).fill(500), ...Array(7).fill(null)]);
|
|
49
|
+
const values = smooth(rows, 365, "level").points.map((p) => p.value);
|
|
50
|
+
expect(values).toEqual([500, 500, null]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("smoothSeries over a year", () => {
|
|
55
|
+
it("draws a 28-day average per day, one point a week ending on the last day", () => {
|
|
56
|
+
const rows = days(Array(70).fill(50));
|
|
57
|
+
const { points, mode, window } = smooth(rows, 365);
|
|
58
|
+
expect(mode).toBe("rolling");
|
|
59
|
+
expect(window).toBe(28);
|
|
60
|
+
expect(points.at(-1)?.date).toBe("2026-10-31");
|
|
61
|
+
expect(points.map((p) => p.date)).toHaveLength(10);
|
|
62
|
+
expect(points.every((p) => p.value === 50)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("flattens a one-day spike into a small bump", () => {
|
|
66
|
+
// The 2 Mar 2026 shape: 1,225 visitors on a day that ran about 60.
|
|
67
|
+
const rows = days([...Array(40).fill(60), 1225, ...Array(29).fill(60)]);
|
|
68
|
+
const peak = Math.max(...smooth(rows, 365).points.map((p) => Number(p.value)));
|
|
69
|
+
expect(peak).toBeLessThan(60 + (1225 - 60) / 28 + 1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("keeps a real climb", () => {
|
|
73
|
+
const climb = Array.from({ length: 112 }, (_, i) => 100 + i * 2);
|
|
74
|
+
const values = smooth(days(climb), 365).points.map((p) => Number(p.value));
|
|
75
|
+
for (let i = 1; i < values.length; i++) expect(values[i]).toBeGreaterThan(values[i - 1]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("names a point by its window", () => {
|
|
79
|
+
expect(averageTo("Oct 31", 28)).toBe("28-day average to Oct 31");
|
|
80
|
+
expect(averageTo("Oct 31")).toBe("7-day average to Oct 31");
|
|
81
|
+
});
|
|
82
|
+
});
|