terminoir 0.6.0 → 0.8.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/README.md +8 -2
- package/dist/components/BucketGrid.svelte +109 -0
- package/dist/components/BucketGrid.svelte.d.ts +25 -0
- package/dist/components/ChipSelect.svelte +228 -0
- package/dist/components/ChipSelect.svelte.d.ts +18 -0
- package/dist/components/DateField.svelte +144 -0
- package/dist/components/DateField.svelte.d.ts +15 -0
- package/dist/components/ExpressionEditor.svelte +186 -0
- package/dist/components/ExpressionEditor.svelte.d.ts +17 -0
- package/dist/components/GridLayout.svelte +194 -0
- package/dist/components/GridLayout.svelte.d.ts +15 -0
- package/dist/components/PropertyRow.svelte +68 -0
- package/dist/components/PropertyRow.svelte.d.ts +12 -0
- package/dist/components/SearchPicker.svelte +2 -0
- package/dist/components/Timeline.svelte +130 -0
- package/dist/components/Timeline.svelte.d.ts +11 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +9 -0
- package/dist/utils/componentTypes.d.ts +12 -0
- package/dist/utils/dayLabel.d.ts +7 -0
- package/dist/utils/dayLabel.js +35 -0
- package/dist/utils/expression.d.ts +49 -0
- package/dist/utils/expression.js +116 -0
- package/dist/utils/layout.d.ts +15 -0
- package/dist/utils/layout.js +27 -0
- package/dist/utils/timeline.d.ts +36 -0
- package/dist/utils/timeline.js +48 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -46,6 +46,11 @@ export { default as Inline } from './components/Inline.svelte';
|
|
|
46
46
|
export { default as Text } from './components/Text.svelte';
|
|
47
47
|
export { default as Disclosure } from './components/Disclosure.svelte';
|
|
48
48
|
export { default as GroupedList } from './components/GroupedList.svelte';
|
|
49
|
+
export { default as Timeline } from './components/Timeline.svelte';
|
|
50
|
+
export { default as BucketGrid } from './components/BucketGrid.svelte';
|
|
51
|
+
export { default as GridLayout } from './components/GridLayout.svelte';
|
|
52
|
+
export { default as ExpressionEditor } from './components/ExpressionEditor.svelte';
|
|
53
|
+
export { describe as describeExpression, isComplete as isCompleteExpression, } from './utils/expression.js';
|
|
49
54
|
export { default as SegmentBar } from './components/SegmentBar.svelte';
|
|
50
55
|
export { default as NavSection } from './components/NavSection.svelte';
|
|
51
56
|
export { default as Spacer } from './components/Spacer.svelte';
|
|
@@ -60,7 +65,11 @@ export { default as List } from './components/List.svelte';
|
|
|
60
65
|
export { default as ListItem } from './components/ListItem.svelte';
|
|
61
66
|
export { default as DescriptionList } from './components/DescriptionList.svelte';
|
|
62
67
|
export { default as FormRow } from './components/FormRow.svelte';
|
|
68
|
+
export { default as PropertyRow } from './components/PropertyRow.svelte';
|
|
63
69
|
// Inputs
|
|
70
|
+
export { default as ChipSelect } from './components/ChipSelect.svelte';
|
|
71
|
+
export { default as DateField } from './components/DateField.svelte';
|
|
72
|
+
export { dayLabel, dayUrgency, parseYmd } from './utils/dayLabel.js';
|
|
64
73
|
export { default as SearchPicker } from './components/SearchPicker.svelte';
|
|
65
74
|
export { default as FileButton } from './components/FileButton.svelte';
|
|
66
75
|
export { default as TitleInput } from './components/TitleInput.svelte';
|
|
@@ -15,6 +15,18 @@ export type PickerOption = {
|
|
|
15
15
|
icon?: string;
|
|
16
16
|
iconColor?: string;
|
|
17
17
|
};
|
|
18
|
+
export type ChipSelectOption = {
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
/** Dot colour (palette name or CSS colour); grey when unset. */
|
|
22
|
+
color?: string;
|
|
23
|
+
/** Id of the `ChipSelectGroup` heading it sits under. */
|
|
24
|
+
group?: string;
|
|
25
|
+
};
|
|
26
|
+
export type ChipSelectGroup = {
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
};
|
|
18
30
|
export type RadioCardOption = {
|
|
19
31
|
value: string;
|
|
20
32
|
label: string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** How far a day is from today, for colouring deadlines. */
|
|
2
|
+
export type DayUrgency = 'past' | 'today' | 'future';
|
|
3
|
+
/** Local-midnight Date for `YYYY-MM-DD`, or null when the text is not a calendar date. */
|
|
4
|
+
export declare function parseYmd(ymd: string): Date | null;
|
|
5
|
+
/** "Today", "Tomorrow", "Yesterday", a weekday within the next week, else "Nov 12" (plus the year when it differs). */
|
|
6
|
+
export declare function dayLabel(date: Date, today?: Date): string;
|
|
7
|
+
export declare function dayUrgency(date: Date, today?: Date): DayUrgency;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { addDays, sameDay, startOfDay } from './calendar.js';
|
|
2
|
+
const DAYS_SHOWN_AS_WEEKDAY = 6;
|
|
3
|
+
const YMD = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
4
|
+
/** Local-midnight Date for `YYYY-MM-DD`, or null when the text is not a calendar date. */
|
|
5
|
+
export function parseYmd(ymd) {
|
|
6
|
+
const match = YMD.exec(ymd.trim());
|
|
7
|
+
if (!match)
|
|
8
|
+
return null;
|
|
9
|
+
const [, year, month, day] = match.map(Number);
|
|
10
|
+
const date = new Date(year, month - 1, day);
|
|
11
|
+
return date.getMonth() === month - 1 && date.getDate() === day ? date : null;
|
|
12
|
+
}
|
|
13
|
+
/** "Today", "Tomorrow", "Yesterday", a weekday within the next week, else "Nov 12" (plus the year when it differs). */
|
|
14
|
+
export function dayLabel(date, today = new Date()) {
|
|
15
|
+
const day = startOfDay(date);
|
|
16
|
+
const now = startOfDay(today);
|
|
17
|
+
if (sameDay(day, now))
|
|
18
|
+
return 'Today';
|
|
19
|
+
if (sameDay(day, addDays(now, 1)))
|
|
20
|
+
return 'Tomorrow';
|
|
21
|
+
if (sameDay(day, addDays(now, -1)))
|
|
22
|
+
return 'Yesterday';
|
|
23
|
+
if (day > now && day <= addDays(now, DAYS_SHOWN_AS_WEEKDAY)) {
|
|
24
|
+
return day.toLocaleDateString(undefined, { weekday: 'long' });
|
|
25
|
+
}
|
|
26
|
+
const withYear = day.getFullYear() !== now.getFullYear();
|
|
27
|
+
return day.toLocaleDateString(undefined, { month: 'short', day: 'numeric', ...(withYear && { year: 'numeric' }) });
|
|
28
|
+
}
|
|
29
|
+
export function dayUrgency(date, today = new Date()) {
|
|
30
|
+
const day = startOfDay(date);
|
|
31
|
+
const now = startOfDay(today);
|
|
32
|
+
if (sameDay(day, now))
|
|
33
|
+
return 'today';
|
|
34
|
+
return day < now ? 'past' : 'future';
|
|
35
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed expression trees for a block editor. The op catalogue (e.g. Oyster's
|
|
3
|
+
* `GET /expressions/ops`) says what each op returns and what its slots accept; the editor only
|
|
4
|
+
* offers ops whose result fits the slot, so users cannot build an ill-typed tree.
|
|
5
|
+
*/
|
|
6
|
+
export type ExpressionKind = 'boolean' | 'number' | 'date' | 'text' | 'value' | 'ids';
|
|
7
|
+
export type ExpressionSlot = {
|
|
8
|
+
name: string;
|
|
9
|
+
kind: ExpressionKind;
|
|
10
|
+
many?: boolean;
|
|
11
|
+
};
|
|
12
|
+
export type ExpressionOp = {
|
|
13
|
+
op: string;
|
|
14
|
+
label: string;
|
|
15
|
+
group: string;
|
|
16
|
+
returns: ExpressionKind;
|
|
17
|
+
slots: ExpressionSlot[];
|
|
18
|
+
/** Plain fields on the node. A param named `key` picks a field; others are typed in. */
|
|
19
|
+
params: string[];
|
|
20
|
+
};
|
|
21
|
+
export type ExpressionField = {
|
|
22
|
+
key: string;
|
|
23
|
+
label?: string;
|
|
24
|
+
kind?: ExpressionKind;
|
|
25
|
+
};
|
|
26
|
+
export type ExpressionNode = {
|
|
27
|
+
op: string;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
};
|
|
30
|
+
/** A slot of `kind` accepts an op returning `returns` (`value` is the wildcard both ways, `ids` only fits `ids`). */
|
|
31
|
+
export declare function fits(kind: ExpressionKind, returns: ExpressionKind): boolean;
|
|
32
|
+
export declare function opsFor(kind: ExpressionKind, ops: ExpressionOp[]): ExpressionOp[];
|
|
33
|
+
/** A fresh node for an op: empty single slots, one empty entry in list slots, blank params. */
|
|
34
|
+
export declare function emptyNode(op: ExpressionOp): ExpressionNode;
|
|
35
|
+
/** Text typed into a param, as the value it stands for (numbers and true/false become typed). */
|
|
36
|
+
export declare function parseParam(param: string, text: string): unknown;
|
|
37
|
+
export declare function paramText(value: unknown): string;
|
|
38
|
+
/** True when every slot is filled and every key param names a field. */
|
|
39
|
+
export declare function isComplete(node: unknown, ops: ExpressionOp[]): boolean;
|
|
40
|
+
/** Plain-English reading of a tree, built from op labels and slot names (no per-op templates). */
|
|
41
|
+
export declare function describe(node: unknown, ops: ExpressionOp[], fields?: ExpressionField[]): string;
|
|
42
|
+
export type ExpressionPath = (string | number)[];
|
|
43
|
+
/** A copy of `root` with the value at `path` replaced (`undefined` removes a list entry). */
|
|
44
|
+
export declare function setAt(root: unknown, path: ExpressionPath, value: unknown): unknown;
|
|
45
|
+
/** Ops grouped by their `group`, in first-seen order, for an `<optgroup>` picker. */
|
|
46
|
+
export declare function groupOps(ops: ExpressionOp[]): {
|
|
47
|
+
group: string;
|
|
48
|
+
ops: ExpressionOp[];
|
|
49
|
+
}[];
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed expression trees for a block editor. The op catalogue (e.g. Oyster's
|
|
3
|
+
* `GET /expressions/ops`) says what each op returns and what its slots accept; the editor only
|
|
4
|
+
* offers ops whose result fits the slot, so users cannot build an ill-typed tree.
|
|
5
|
+
*/
|
|
6
|
+
/** A slot of `kind` accepts an op returning `returns` (`value` is the wildcard both ways, `ids` only fits `ids`). */
|
|
7
|
+
export function fits(kind, returns) {
|
|
8
|
+
if (kind === 'ids' || returns === 'ids')
|
|
9
|
+
return kind === returns;
|
|
10
|
+
return kind === 'value' || returns === 'value' || kind === returns;
|
|
11
|
+
}
|
|
12
|
+
export function opsFor(kind, ops) {
|
|
13
|
+
return ops.filter((op) => fits(kind, op.returns));
|
|
14
|
+
}
|
|
15
|
+
/** A fresh node for an op: empty single slots, one empty entry in list slots, blank params. */
|
|
16
|
+
export function emptyNode(op) {
|
|
17
|
+
const node = { op: op.op };
|
|
18
|
+
for (const slot of op.slots)
|
|
19
|
+
node[slot.name] = slot.many ? [] : null;
|
|
20
|
+
for (const param of op.params)
|
|
21
|
+
node[param] = param === 'options' ? [] : '';
|
|
22
|
+
return node;
|
|
23
|
+
}
|
|
24
|
+
/** Text typed into a param, as the value it stands for (numbers and true/false become typed). */
|
|
25
|
+
export function parseParam(param, text) {
|
|
26
|
+
if (param === 'options')
|
|
27
|
+
return text.split(',').map((s) => s.trim()).filter(Boolean);
|
|
28
|
+
const trimmed = text.trim();
|
|
29
|
+
if (param === 'key')
|
|
30
|
+
return trimmed;
|
|
31
|
+
if (trimmed === 'true' || trimmed === 'false')
|
|
32
|
+
return trimmed === 'true';
|
|
33
|
+
if (trimmed !== '' && !Number.isNaN(Number(trimmed)))
|
|
34
|
+
return Number(trimmed);
|
|
35
|
+
return text;
|
|
36
|
+
}
|
|
37
|
+
export function paramText(value) {
|
|
38
|
+
if (Array.isArray(value))
|
|
39
|
+
return value.join(', ');
|
|
40
|
+
return value == null ? '' : String(value);
|
|
41
|
+
}
|
|
42
|
+
/** True when every slot is filled and every key param names a field. */
|
|
43
|
+
export function isComplete(node, ops) {
|
|
44
|
+
if (!node || typeof node !== 'object')
|
|
45
|
+
return false;
|
|
46
|
+
const n = node;
|
|
47
|
+
const spec = ops.find((o) => o.op === n.op);
|
|
48
|
+
if (!spec)
|
|
49
|
+
return false;
|
|
50
|
+
if (spec.params.includes('key') && !n.key)
|
|
51
|
+
return false;
|
|
52
|
+
return spec.slots.every((slot) => {
|
|
53
|
+
const value = n[slot.name];
|
|
54
|
+
if (slot.many)
|
|
55
|
+
return Array.isArray(value) && value.length > 0 && value.every((v) => isComplete(v, ops));
|
|
56
|
+
return isComplete(value, ops);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** Plain-English reading of a tree, built from op labels and slot names (no per-op templates). */
|
|
60
|
+
export function describe(node, ops, fields = []) {
|
|
61
|
+
if (!node || typeof node !== 'object')
|
|
62
|
+
return '…';
|
|
63
|
+
const n = node;
|
|
64
|
+
const spec = ops.find((o) => o.op === n.op);
|
|
65
|
+
if (!spec)
|
|
66
|
+
return n.op;
|
|
67
|
+
const label = spec.label.toLowerCase();
|
|
68
|
+
if ('key' in n && spec.params.includes('key')) {
|
|
69
|
+
const field = fields.find((f) => f.key === n.key);
|
|
70
|
+
const name = field?.label ?? (n.key ? String(n.key) : '…');
|
|
71
|
+
return spec.slots.length ? `${label} ${name}` : spec.op === 'field' ? name : `${label} ${name}`;
|
|
72
|
+
}
|
|
73
|
+
if (spec.params.includes('value') && !spec.slots.length)
|
|
74
|
+
return JSON.stringify(n.value ?? '');
|
|
75
|
+
const part = (v) => describe(v, ops, fields);
|
|
76
|
+
const names = spec.slots.map((s) => s.name);
|
|
77
|
+
if (names.length === 2 && names[0] === 'left')
|
|
78
|
+
return `${part(n.left)} ${label} ${part(n.right)}`;
|
|
79
|
+
const many = spec.slots.find((s) => s.many);
|
|
80
|
+
if (many) {
|
|
81
|
+
const items = (Array.isArray(n[many.name]) ? n[many.name] : []).map(part);
|
|
82
|
+
return items.length > 1 ? `(${items.join(` ${label} `)})` : (items[0] ?? '…');
|
|
83
|
+
}
|
|
84
|
+
const pieces = spec.slots.map((s) => part(n[s.name]));
|
|
85
|
+
const options = spec.params.includes('options') ? ` ${paramText(n.options)}` : '';
|
|
86
|
+
if (!pieces.length)
|
|
87
|
+
return label;
|
|
88
|
+
if (pieces.length > 1)
|
|
89
|
+
return `${label} ${pieces.join(', ')}`;
|
|
90
|
+
// "is empty", "is one of" read after their argument; "not", "days until" before it.
|
|
91
|
+
return label.startsWith('is ') ? `${pieces[0]} ${label}${options}` : `${label} ${pieces[0]}`;
|
|
92
|
+
}
|
|
93
|
+
/** A copy of `root` with the value at `path` replaced (`undefined` removes a list entry). */
|
|
94
|
+
export function setAt(root, path, value) {
|
|
95
|
+
if (!path.length)
|
|
96
|
+
return value;
|
|
97
|
+
const [head, ...rest] = path;
|
|
98
|
+
if (Array.isArray(root)) {
|
|
99
|
+
const next = root.slice();
|
|
100
|
+
const index = Number(head);
|
|
101
|
+
if (!rest.length && value === undefined)
|
|
102
|
+
next.splice(index, 1);
|
|
103
|
+
else
|
|
104
|
+
next[index] = setAt(root[index], rest, value);
|
|
105
|
+
return next;
|
|
106
|
+
}
|
|
107
|
+
const obj = (root && typeof root === 'object' ? root : {});
|
|
108
|
+
return { ...obj, [head]: setAt(obj[head], rest, value) };
|
|
109
|
+
}
|
|
110
|
+
/** Ops grouped by their `group`, in first-seen order, for an `<optgroup>` picker. */
|
|
111
|
+
export function groupOps(ops) {
|
|
112
|
+
const groups = new Map();
|
|
113
|
+
for (const op of ops)
|
|
114
|
+
groups.set(op.group, [...(groups.get(op.group) ?? []), op]);
|
|
115
|
+
return [...groups].map(([group, list]) => ({ group, ops: list }));
|
|
116
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Reading-order grid layout helpers: drag to reorder, drag an edge to change column span. */
|
|
2
|
+
export type GridItem = {
|
|
3
|
+
id: string;
|
|
4
|
+
span: number;
|
|
5
|
+
};
|
|
6
|
+
export declare function moveTo<T extends {
|
|
7
|
+
id: string;
|
|
8
|
+
}>(items: T[], id: string, index: number): T[];
|
|
9
|
+
/** Index of the item whose centre is nearest to a point (ties keep the earlier one). */
|
|
10
|
+
export declare function nearestIndex(centres: {
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
}[], x: number, y: number): number;
|
|
14
|
+
/** Column span after dragging an edge by `dx` pixels, snapped to whole columns and clamped. */
|
|
15
|
+
export declare function spanAfterDrag(span: number, dx: number, columnWidth: number, min: number, max: number): number;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Reading-order grid layout helpers: drag to reorder, drag an edge to change column span. */
|
|
2
|
+
export function moveTo(items, id, index) {
|
|
3
|
+
const from = items.findIndex((i) => i.id === id);
|
|
4
|
+
if (from < 0)
|
|
5
|
+
return items;
|
|
6
|
+
const next = items.slice();
|
|
7
|
+
const [item] = next.splice(from, 1);
|
|
8
|
+
next.splice(Math.max(0, Math.min(index, next.length)), 0, item);
|
|
9
|
+
return next;
|
|
10
|
+
}
|
|
11
|
+
/** Index of the item whose centre is nearest to a point (ties keep the earlier one). */
|
|
12
|
+
export function nearestIndex(centres, x, y) {
|
|
13
|
+
let best = 0;
|
|
14
|
+
let bestDistance = Infinity;
|
|
15
|
+
centres.forEach((c, i) => {
|
|
16
|
+
const distance = (c.x - x) ** 2 + (c.y - y) ** 2;
|
|
17
|
+
if (distance < bestDistance)
|
|
18
|
+
[best, bestDistance] = [i, distance];
|
|
19
|
+
});
|
|
20
|
+
return best;
|
|
21
|
+
}
|
|
22
|
+
/** Column span after dragging an edge by `dx` pixels, snapped to whole columns and clamped. */
|
|
23
|
+
export function spanAfterDrag(span, dx, columnWidth, min, max) {
|
|
24
|
+
if (columnWidth <= 0)
|
|
25
|
+
return span;
|
|
26
|
+
return Math.max(min, Math.min(max, span + Math.round(dx / columnWidth)));
|
|
27
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type TimelineItem = {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
start: Date;
|
|
5
|
+
/** Inclusive last day; defaults to `start` (a one-day bar). */
|
|
6
|
+
end?: Date;
|
|
7
|
+
/** Palette name or CSS colour for the bar. */
|
|
8
|
+
color?: string;
|
|
9
|
+
/** Rows sharing a group sit under one heading. */
|
|
10
|
+
group?: string;
|
|
11
|
+
};
|
|
12
|
+
export type TimelineBar = {
|
|
13
|
+
item: TimelineItem;
|
|
14
|
+
left: number;
|
|
15
|
+
width: number;
|
|
16
|
+
};
|
|
17
|
+
export type TimelineRow = {
|
|
18
|
+
group: string | null;
|
|
19
|
+
bars: TimelineBar[];
|
|
20
|
+
};
|
|
21
|
+
export type TimelineScale = {
|
|
22
|
+
start: Date;
|
|
23
|
+
days: number;
|
|
24
|
+
ticks: {
|
|
25
|
+
date: Date;
|
|
26
|
+
left: number;
|
|
27
|
+
}[];
|
|
28
|
+
};
|
|
29
|
+
/** The day range covering every bar (padded a day each side) and evenly spaced day ticks. */
|
|
30
|
+
export declare function timelineScale(items: TimelineItem[], today?: Date): TimelineScale;
|
|
31
|
+
/** One bar per item as left/width percentages of the scale; ends before starts are clamped. */
|
|
32
|
+
export declare function timelineBar(item: TimelineItem, scale: TimelineScale): TimelineBar;
|
|
33
|
+
/** Bars in groups (first-seen order; ungrouped first), each group sorted by start. */
|
|
34
|
+
export declare function timelineRows(items: TimelineItem[], scale: TimelineScale): TimelineRow[];
|
|
35
|
+
/** Where today falls on the scale (percent), or null when outside it. */
|
|
36
|
+
export declare function todayOffset(scale: TimelineScale, today?: Date): number | null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { addDays, startOfDay } from './calendar.js';
|
|
2
|
+
const DAY_MS = 86_400_000;
|
|
3
|
+
const PAD_DAYS = 1;
|
|
4
|
+
const MAX_TICKS = 12;
|
|
5
|
+
function dayIndex(from, d) {
|
|
6
|
+
return Math.round((startOfDay(d).getTime() - from.getTime()) / DAY_MS);
|
|
7
|
+
}
|
|
8
|
+
/** The day range covering every bar (padded a day each side) and evenly spaced day ticks. */
|
|
9
|
+
export function timelineScale(items, today = new Date()) {
|
|
10
|
+
const starts = items.map((i) => startOfDay(i.start).getTime());
|
|
11
|
+
const ends = items.map((i) => startOfDay(i.end ?? i.start).getTime());
|
|
12
|
+
const first = starts.length ? Math.min(...starts) : startOfDay(today).getTime();
|
|
13
|
+
const last = ends.length ? Math.max(...ends) : first;
|
|
14
|
+
const start = addDays(new Date(first), -PAD_DAYS);
|
|
15
|
+
const days = Math.round((last - first) / DAY_MS) + 1 + 2 * PAD_DAYS;
|
|
16
|
+
const step = Math.max(1, Math.ceil(days / MAX_TICKS));
|
|
17
|
+
const ticks = [];
|
|
18
|
+
for (let i = 0; i < days; i += step)
|
|
19
|
+
ticks.push({ date: addDays(start, i), left: (i / days) * 100 });
|
|
20
|
+
return { start, days, ticks };
|
|
21
|
+
}
|
|
22
|
+
/** One bar per item as left/width percentages of the scale; ends before starts are clamped. */
|
|
23
|
+
export function timelineBar(item, scale) {
|
|
24
|
+
const from = dayIndex(scale.start, item.start);
|
|
25
|
+
const to = Math.max(from, dayIndex(scale.start, item.end ?? item.start));
|
|
26
|
+
return { item, left: (from / scale.days) * 100, width: ((to - from + 1) / scale.days) * 100 };
|
|
27
|
+
}
|
|
28
|
+
/** Bars in groups (first-seen order; ungrouped first), each group sorted by start. */
|
|
29
|
+
export function timelineRows(items, scale) {
|
|
30
|
+
const groups = new Map();
|
|
31
|
+
for (const item of items) {
|
|
32
|
+
const key = item.group ?? null;
|
|
33
|
+
groups.set(key, [...(groups.get(key) ?? []), item]);
|
|
34
|
+
}
|
|
35
|
+
const keys = [...groups.keys()].sort((a, b) => (a === null ? -1 : b === null ? 1 : 0));
|
|
36
|
+
return keys.map((group) => ({
|
|
37
|
+
group,
|
|
38
|
+
bars: (groups.get(group) ?? [])
|
|
39
|
+
.slice()
|
|
40
|
+
.sort((a, b) => a.start.getTime() - b.start.getTime())
|
|
41
|
+
.map((item) => timelineBar(item, scale)),
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
/** Where today falls on the scale (percent), or null when outside it. */
|
|
45
|
+
export function todayOffset(scale, today = new Date()) {
|
|
46
|
+
const i = dayIndex(scale.start, today);
|
|
47
|
+
return i < 0 || i >= scale.days ? null : ((i + 0.5) / scale.days) * 100;
|
|
48
|
+
}
|