@heyramzi/ui 2.22.0 → 2.23.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/canvas/editor/rebase.js +26 -2
- package/dist/editor/safe-url.js +26 -0
- package/dist/editor/serializer.js +10 -4
- package/dist/editor/slash-query.js +10 -0
- package/dist/triage/index.js +4 -0
- package/dist/triage/types.js +12 -0
- package/package.json +7 -1
- package/scripts/sync-shadcn.mjs +4 -6
- package/src/animations/modal.css +4 -1
- package/src/canvas/editor/rebase.test.ts +37 -0
- package/src/canvas/editor/rebase.ts +33 -1
- package/src/canvas/ui/canvas-grid.svelte +72 -12
- package/src/canvas/ui/canvas-ui.css +32 -1
- package/src/charts/area-chart.svelte +2 -2
- package/src/charts/bar-chart.svelte +2 -2
- package/src/charts/chart-tokens.css +19 -0
- package/src/charts/dashboard/stat-tile.svelte +1 -1
- package/src/charts/donut-chart.svelte +1 -1
- package/src/charts/gauge-chart.svelte +1 -1
- package/src/charts/line-chart.svelte +2 -2
- package/src/charts/number-chart.svelte +1 -1
- package/src/charts/ring-chart.svelte +2 -2
- package/src/charts/sparkline.svelte +1 -1
- package/src/charts/stat-card.svelte +1 -1
- package/src/charts/time-series-chart.svelte +1 -1
- package/src/editor/editor.svelte +7 -5
- package/src/editor/safe-url.ts +32 -0
- package/src/editor/serializer.test.ts +58 -0
- package/src/editor/serializer.ts +9 -6
- package/src/editor/slash-query.test.ts +19 -0
- package/src/editor/slash-query.ts +11 -0
- package/src/modules.test.ts +13 -1
- package/src/shadcn/alert-dialog/alert-dialog-overlay.svelte +1 -1
- package/src/shadcn/button/button.svelte +1 -1
- package/src/shadcn/button/button.test.ts +2 -2
- package/src/shadcn/command/command-dialog.svelte +5 -10
- package/src/shadcn/dialog/dialog-overlay.svelte +1 -1
- package/src/shadcn/input/input.svelte +2 -2
- package/src/shadcn/sheet/sheet-overlay.svelte +1 -1
- package/src/tokens/craft.css +30 -0
- package/src/triage/README.md +28 -0
- package/src/triage/index.ts +10 -0
- package/src/triage/triage-plan.svelte +132 -0
- package/src/triage/triage-queue.svelte +48 -0
- package/src/triage/triage-task.svelte +95 -0
- package/src/triage/types.ts +35 -0
- package/src/work/kanban-column.svelte +5 -3
- package/src/work/time/time-chip.svelte +1 -3
|
@@ -151,7 +151,10 @@ function patchEdgeOps(before, edge) {
|
|
|
151
151
|
function edgeOps(base, current) {
|
|
152
152
|
const baseEdges = new Map(base.edges.map((e) => [e.id, e]));
|
|
153
153
|
const currentIds = new Set(current.edges.map((e) => e.id));
|
|
154
|
-
const
|
|
154
|
+
const currentNodeIds = new Set(current.nodes.map((n) => n.id));
|
|
155
|
+
const removedNodeIds = new Set(base.nodes.filter((n) => !currentNodeIds.has(n.id)).map((n) => n.id));
|
|
156
|
+
// WHY: removeNode already takes the node's edges, and removeEdge throws on a missing id.
|
|
157
|
+
const removed = base.edges.filter((e) => !currentIds.has(e.id)).filter((e) => !removedNodeIds.has(String(e.source)) && !removedNodeIds.has(String(e.target))).map((e) => ({
|
|
155
158
|
kind: "removeEdge",
|
|
156
159
|
edgeId: e.id
|
|
157
160
|
}));
|
|
@@ -238,11 +241,32 @@ function parentsFirst(nodes) {
|
|
|
238
241
|
return sortParentsFirst(wrapped).map(({ node }) => node);
|
|
239
242
|
}
|
|
240
243
|
/**
|
|
244
|
+
* Drops removals the server already made, so a delete on both sides merges. Only
|
|
245
|
+
* removeNode and removeEdge: an edit to a node the server deleted must still throw.
|
|
246
|
+
*/
|
|
247
|
+
function withoutServerRemovals(serverDoc, ops) {
|
|
248
|
+
const serverNodeIds = new Set(serverDoc.nodes.map((n) => n.id));
|
|
249
|
+
const serverEdges = new Map(serverDoc.edges.map((e) => [e.id, e]));
|
|
250
|
+
const removedNodeIds = new Set();
|
|
251
|
+
return ops.filter((op) => {
|
|
252
|
+
if (op.kind === "removeNode") {
|
|
253
|
+
if (!serverNodeIds.has(op.nodeId)) return false;
|
|
254
|
+
removedNodeIds.add(op.nodeId);
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
if (op.kind === "removeEdge") {
|
|
258
|
+
const edge = serverEdges.get(op.edgeId);
|
|
259
|
+
return edge !== undefined && !removedNodeIds.has(String(edge.source)) && !removedNodeIds.has(String(edge.target));
|
|
260
|
+
}
|
|
261
|
+
return true;
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
241
265
|
* Replay local changes (lastSaved -> local) on the server document. Returns
|
|
242
266
|
* null when impossible, like a patched node deleted, so the save error shows.
|
|
243
267
|
*/
|
|
244
268
|
export function rebaseDiagram(serverDoc, lastSaved, local) {
|
|
245
|
-
const ops = diffToOps(lastSaved, local);
|
|
269
|
+
const ops = withoutServerRemovals(serverDoc, diffToOps(lastSaved, local));
|
|
246
270
|
const res = tryCatch(() => applyOps({
|
|
247
271
|
nodes: serverDoc.nodes,
|
|
248
272
|
edges: serverDoc.edges
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const SAFE_SCHEME = /^(?:https?:|mailto:|tel:)/i;
|
|
2
|
+
const ANY_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
3
|
+
// WHY no SVG: an SVG can carry script, and pasted images are stored as these data: URLs.
|
|
4
|
+
const SAFE_IMAGE_DATA = /^data:image\/(?:png|jpe?g|gif|webp|avif);base64,/i;
|
|
5
|
+
// WHY: browsers drop tabs, newlines and control bytes from a URL, so "java\tscript:" runs as javascript:.
|
|
6
|
+
function clean(raw) {
|
|
7
|
+
return [...raw].filter((c) => c.charCodeAt(0) > 31 && c.charCodeAt(0) !== 127).join("").trim();
|
|
8
|
+
}
|
|
9
|
+
// The URL when it's safe to put in an href, else null.
|
|
10
|
+
export function safeUrl(raw) {
|
|
11
|
+
if (!raw) return null;
|
|
12
|
+
const href = clean(raw);
|
|
13
|
+
// Relative URLs and same-page anchors carry no scheme, so they are safe.
|
|
14
|
+
if (href.startsWith("#") || href.startsWith("/") || href.startsWith(".")) return href;
|
|
15
|
+
if (SAFE_SCHEME.test(href)) return href;
|
|
16
|
+
if (!ANY_SCHEME.test(href)) return href;
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
// The URL when it's safe to put in an img src, else null.
|
|
20
|
+
export function safeImageUrl(raw) {
|
|
21
|
+
const url = safeUrl(raw);
|
|
22
|
+
if (url !== null) return url;
|
|
23
|
+
if (!raw) return null;
|
|
24
|
+
const src = clean(raw);
|
|
25
|
+
return SAFE_IMAGE_DATA.test(src) ? src : null;
|
|
26
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { safeImageUrl, safeUrl } from "./safe-url.js";
|
|
1
2
|
// WHY our own slugify: the website's uses NFKD, which strips accents from French
|
|
2
3
|
// headings. The vibe-kit utils one doesn't, so accented anchors would differ.
|
|
3
4
|
function slugify(input, maxLength = 60) {
|
|
@@ -99,7 +100,7 @@ function numString(attrs, key) {
|
|
|
99
100
|
return value === undefined ? undefined : String(value);
|
|
100
101
|
}
|
|
101
102
|
function renderImage(node) {
|
|
102
|
-
const src = strAttr(node.attrs, "src");
|
|
103
|
+
const src = safeImageUrl(strAttr(node.attrs, "src"));
|
|
103
104
|
if (!src) return "";
|
|
104
105
|
const attrs = renderAttributes({
|
|
105
106
|
alt: strAttr(node.attrs, "alt") ?? "",
|
|
@@ -237,10 +238,15 @@ const MARK_TAGS = {
|
|
|
237
238
|
};
|
|
238
239
|
function wrapMark(content, mark) {
|
|
239
240
|
if (mark.type === "link") {
|
|
241
|
+
// WHY: stored docs come from teammates, clients and CLIs that skip the editor's link filter.
|
|
242
|
+
const href = safeUrl(strAttr(mark.attrs, "href"));
|
|
243
|
+
if (!href) return content;
|
|
244
|
+
const target = strAttr(mark.attrs, "target");
|
|
245
|
+
const rel = strAttr(mark.attrs, "rel") || (target ? "noopener noreferrer" : undefined);
|
|
240
246
|
const attrs = renderAttributes({
|
|
241
|
-
href
|
|
242
|
-
target
|
|
243
|
-
rel
|
|
247
|
+
href,
|
|
248
|
+
target,
|
|
249
|
+
rel
|
|
244
250
|
});
|
|
245
251
|
return `<a${attrs}>${content}</a>`;
|
|
246
252
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// One character of context, the slash, and a query of up to 20.
|
|
2
|
+
export const SLASH_WINDOW = 22;
|
|
3
|
+
/**
|
|
4
|
+
* The slash command typed just before the caret, or null when there isn't one. The slash has to
|
|
5
|
+
* start the text or follow whitespace, so `TCP/IP` and `and/or` stay plain text.
|
|
6
|
+
*/
|
|
7
|
+
export function slashQueryAt(textBefore) {
|
|
8
|
+
const match = textBefore.match(/(?:^|\s)\/([a-zA-Z0-9 ]{0,20})$/);
|
|
9
|
+
return match ? match[1] : null;
|
|
10
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { default as TriagePlan } from "../../src/triage/triage-plan.svelte";
|
|
2
|
+
export { default as TriageTask } from "../../src/triage/triage-task.svelte";
|
|
3
|
+
export { default as TriageQueue } from "../../src/triage/triage-queue.svelte";
|
|
4
|
+
export { DEFAULT_TRIAGE_PLAN_LABELS, DEFAULT_TRIAGE_TASK_LABELS } from "./types.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const DEFAULT_TRIAGE_PLAN_LABELS = {
|
|
2
|
+
approve: "Approve",
|
|
3
|
+
pick: "Pick another task",
|
|
4
|
+
dismiss: "Dismiss",
|
|
5
|
+
taskLabel: "Task",
|
|
6
|
+
useTask: "Use this task",
|
|
7
|
+
cancel: "Cancel"
|
|
8
|
+
};
|
|
9
|
+
export const DEFAULT_TRIAGE_TASK_LABELS = {
|
|
10
|
+
undo: "Undo",
|
|
11
|
+
openTask: "Open task"
|
|
12
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyramzi/ui",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.23.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",
|
|
@@ -225,6 +225,12 @@
|
|
|
225
225
|
"node": "./dist/inbox/index.js",
|
|
226
226
|
"default": "./src/inbox/index.ts"
|
|
227
227
|
},
|
|
228
|
+
"./triage": {
|
|
229
|
+
"svelte": "./src/triage/index.ts",
|
|
230
|
+
"types": "./src/triage/index.ts",
|
|
231
|
+
"node": "./dist/triage/index.js",
|
|
232
|
+
"default": "./src/triage/index.ts"
|
|
233
|
+
},
|
|
228
234
|
"./work": {
|
|
229
235
|
"svelte": "./src/work/index.ts",
|
|
230
236
|
"types": "./src/work/index.ts",
|
package/scripts/sync-shadcn.mjs
CHANGED
|
@@ -141,24 +141,22 @@ const publicImports = (demo) =>
|
|
|
141
141
|
.join("\n");
|
|
142
142
|
|
|
143
143
|
/* The section's list, generated: a primitive reaches the page by being synced,
|
|
144
|
-
* never by being typed into an array.
|
|
144
|
+
* never by being typed into an array. Names and words only: the site's navigation reads this
|
|
145
|
+
* list on every page, and each demo loads from `./<name>.svelte` on its own page. */
|
|
145
146
|
async function writeManifest(demos) {
|
|
146
147
|
const rows = demos.map(
|
|
147
148
|
(d) =>
|
|
148
|
-
` { name: ${JSON.stringify(d.name)}, title: ${JSON.stringify(d.title)}, note: ${JSON.stringify(d.note)}, path: ${JSON.stringify(d.imports)}
|
|
149
|
+
` { name: ${JSON.stringify(d.name)}, title: ${JSON.stringify(d.title)}, note: ${JSON.stringify(d.note)}, path: ${JSON.stringify(d.imports)} },`,
|
|
149
150
|
);
|
|
150
151
|
const text = [
|
|
151
152
|
"/* Generated by scripts/sync-shadcn.mjs. Do not edit: the demos are upstream's",
|
|
152
|
-
" * own, and the sync rewrites this list. */",
|
|
153
|
-
'import type { Component } from "svelte";',
|
|
154
|
-
...demos.map((d) => `import ${pascal(d.name)}Demo from "./${d.name}.svelte";`),
|
|
153
|
+
" * own, and the sync rewrites this list. Each demo loads from `./<name>.svelte`. */",
|
|
155
154
|
"",
|
|
156
155
|
"export type ShadcnDemo = {",
|
|
157
156
|
" name: string;",
|
|
158
157
|
" title: string;",
|
|
159
158
|
" note: string;",
|
|
160
159
|
" path: string;",
|
|
161
|
-
" Comp: Component;",
|
|
162
160
|
"};",
|
|
163
161
|
"",
|
|
164
162
|
"export const SHADCN_DEMOS: ShadcnDemo[] = [",
|
package/src/animations/modal.css
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
--modal-scale: var(--scale-large, 0.96);
|
|
8
8
|
--modal-scale-close: var(--scale-large, 0.96);
|
|
9
9
|
--modal-ease: var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
|
|
10
|
-
--modal-backdrop-color: rgb(0 0 0 / 0.
|
|
10
|
+
--modal-backdrop-color: var(--scrim, rgb(0 0 0 / 0.32));
|
|
11
|
+
--modal-backdrop-blur: var(--scrim-blur, 2px);
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
/* === Upstream: class-based toggle === */
|
|
@@ -54,6 +55,8 @@
|
|
|
54
55
|
/* WHY: pair .t-modal-backdrop with the modal so the overlay fades in sync. */
|
|
55
56
|
.t-modal-backdrop {
|
|
56
57
|
background: var(--modal-backdrop-color);
|
|
58
|
+
-webkit-backdrop-filter: blur(var(--modal-backdrop-blur));
|
|
59
|
+
backdrop-filter: blur(var(--modal-backdrop-blur));
|
|
57
60
|
opacity: 0;
|
|
58
61
|
transition: opacity var(--modal-open-dur) var(--modal-ease);
|
|
59
62
|
}
|
|
@@ -62,6 +62,15 @@ describe("diffToOps", () => {
|
|
|
62
62
|
const replayed = applyOps({ nodes: [], edges: [] }, diffToOps(base, current));
|
|
63
63
|
expect(replayed.nodes).toHaveLength(2);
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
it("replays the delete of a connected node", () => {
|
|
67
|
+
const base = doc([n("a"), n("b")], [{ id: "e1", source: "a", target: "b" }]);
|
|
68
|
+
const current = doc([n("a")]);
|
|
69
|
+
|
|
70
|
+
const replayed = applyOps({ nodes: base.nodes, edges: base.edges }, diffToOps(base, current));
|
|
71
|
+
expect(replayed.nodes).toHaveLength(1);
|
|
72
|
+
expect(replayed.edges).toHaveLength(0);
|
|
73
|
+
});
|
|
65
74
|
});
|
|
66
75
|
|
|
67
76
|
describe("rebaseDiagram", () => {
|
|
@@ -186,6 +195,34 @@ describe("rebaseDiagram", () => {
|
|
|
186
195
|
const local = doc([n("a"), n("b", { data: { label: "edited" } })]);
|
|
187
196
|
expect(rebaseDiagram(serverDoc, lastSaved, local)).toBeNull();
|
|
188
197
|
});
|
|
198
|
+
|
|
199
|
+
describe("deletes", () => {
|
|
200
|
+
const connected = doc([n("a"), n("b")], [{ id: "e1", source: "a", target: "b" }]);
|
|
201
|
+
|
|
202
|
+
it("merges a local delete of a connected node", () => {
|
|
203
|
+
const server = doc([n("a"), n("b"), n("c")], [{ id: "e1", source: "a", target: "b" }]);
|
|
204
|
+
const local = doc([n("a")]);
|
|
205
|
+
|
|
206
|
+
const rebased = expectDefined(rebaseDiagram(server, connected, local), "rebased doc");
|
|
207
|
+
expect(rebased.nodes.map((node) => node.id)).toEqual(["a", "c"]);
|
|
208
|
+
expect(rebased.edges).toEqual([]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("merges when both sides deleted the same node", () => {
|
|
212
|
+
const server = doc([n("a"), n("c")]);
|
|
213
|
+
const local = doc([n("a")]);
|
|
214
|
+
|
|
215
|
+
const rebased = expectDefined(rebaseDiagram(server, connected, local), "rebased doc");
|
|
216
|
+
expect(rebased.nodes.map((node) => node.id)).toEqual(["a", "c"]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("merges when both sides deleted the same edge", () => {
|
|
220
|
+
const both = doc([n("a"), n("b")]);
|
|
221
|
+
|
|
222
|
+
const rebased = expectDefined(rebaseDiagram(both, connected, both), "rebased doc");
|
|
223
|
+
expect(rebased.edges).toHaveLength(0);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
189
226
|
});
|
|
190
227
|
|
|
191
228
|
describe("a new edge keeps everything it was drawn with", () => {
|
|
@@ -164,8 +164,14 @@ function patchEdgeOps(before: DocEdge, edge: DocEdge): CanvasOp[] {
|
|
|
164
164
|
function edgeOps(base: DiagramDoc, current: DiagramDoc): CanvasOp[] {
|
|
165
165
|
const baseEdges = new Map(base.edges.map((e) => [e.id, e]));
|
|
166
166
|
const currentIds = new Set(current.edges.map((e) => e.id));
|
|
167
|
+
const currentNodeIds = new Set(current.nodes.map((n) => n.id));
|
|
168
|
+
const removedNodeIds = new Set(
|
|
169
|
+
base.nodes.filter((n) => !currentNodeIds.has(n.id)).map((n) => n.id),
|
|
170
|
+
);
|
|
171
|
+
// WHY: removeNode already takes the node's edges, and removeEdge throws on a missing id.
|
|
167
172
|
const removed: CanvasOp[] = base.edges
|
|
168
173
|
.filter((e) => !currentIds.has(e.id))
|
|
174
|
+
.filter((e) => !removedNodeIds.has(String(e.source)) && !removedNodeIds.has(String(e.target)))
|
|
169
175
|
.map((e) => ({ kind: "removeEdge", edgeId: e.id }));
|
|
170
176
|
const upserted = current.edges.flatMap((edge) => {
|
|
171
177
|
const before = baseEdges.get(edge.id);
|
|
@@ -255,6 +261,32 @@ function parentsFirst(nodes: Record<string, unknown>[]): Record<string, unknown>
|
|
|
255
261
|
return sortParentsFirst(wrapped).map(({ node }) => node);
|
|
256
262
|
}
|
|
257
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Drops removals the server already made, so a delete on both sides merges. Only
|
|
266
|
+
* removeNode and removeEdge: an edit to a node the server deleted must still throw.
|
|
267
|
+
*/
|
|
268
|
+
function withoutServerRemovals(serverDoc: DiagramDoc, ops: CanvasOp[]): CanvasOp[] {
|
|
269
|
+
const serverNodeIds = new Set(serverDoc.nodes.map((n) => n.id));
|
|
270
|
+
const serverEdges = new Map(serverDoc.edges.map((e) => [e.id, e]));
|
|
271
|
+
const removedNodeIds = new Set<string>();
|
|
272
|
+
return ops.filter((op) => {
|
|
273
|
+
if (op.kind === "removeNode") {
|
|
274
|
+
if (!serverNodeIds.has(op.nodeId)) return false;
|
|
275
|
+
removedNodeIds.add(op.nodeId);
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
if (op.kind === "removeEdge") {
|
|
279
|
+
const edge = serverEdges.get(op.edgeId);
|
|
280
|
+
return (
|
|
281
|
+
edge !== undefined &&
|
|
282
|
+
!removedNodeIds.has(String(edge.source)) &&
|
|
283
|
+
!removedNodeIds.has(String(edge.target))
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return true;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
258
290
|
/**
|
|
259
291
|
* Replay local changes (lastSaved -> local) on the server document. Returns
|
|
260
292
|
* null when impossible, like a patched node deleted, so the save error shows.
|
|
@@ -264,7 +296,7 @@ export function rebaseDiagram(
|
|
|
264
296
|
lastSaved: DiagramDoc,
|
|
265
297
|
local: DiagramDoc,
|
|
266
298
|
): RawCanvasData | null {
|
|
267
|
-
const ops = diffToOps(lastSaved, local);
|
|
299
|
+
const ops = withoutServerRemovals(serverDoc, diffToOps(lastSaved, local));
|
|
268
300
|
const res = tryCatch(() => applyOps({ nodes: serverDoc.nodes, edges: serverDoc.edges }, ops));
|
|
269
301
|
if (!res.ok) return null;
|
|
270
302
|
const nodes = applyNodeFieldPatches(res.value.nodes, nodeFieldPatches(lastSaved, local));
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
color?: string;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** x, y, width, height in device pixels. */
|
|
20
|
+
type Rect = [number, number, number, number];
|
|
21
|
+
|
|
19
22
|
// =========================
|
|
20
23
|
// CONSTANTS
|
|
21
24
|
// =========================
|
|
@@ -58,6 +61,16 @@
|
|
|
58
61
|
// Pan redraws every frame, so a tile is built once per size and colour.
|
|
59
62
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- render cache written inside the draw effect, never rendered
|
|
60
63
|
const tiles = new Map<string, HTMLCanvasElement>();
|
|
64
|
+
// What the canvas holds now, its origin in device pixels. Plain: only the draw effect reads it.
|
|
65
|
+
let painted: {
|
|
66
|
+
zoom: number;
|
|
67
|
+
ratio: number;
|
|
68
|
+
fill: string;
|
|
69
|
+
w: number;
|
|
70
|
+
h: number;
|
|
71
|
+
x: number;
|
|
72
|
+
y: number;
|
|
73
|
+
} | null = null;
|
|
61
74
|
|
|
62
75
|
function dotTile(step: number, radius: number, fill: string) {
|
|
63
76
|
const key = `${step}:${radius.toFixed(2)}:${fill}`;
|
|
@@ -77,21 +90,20 @@
|
|
|
77
90
|
return tile;
|
|
78
91
|
}
|
|
79
92
|
|
|
93
|
+
/** Fills the given device-pixel rects with the dots, the grid's origin at `originX`, `originY`. */
|
|
80
94
|
// fallow-ignore-next-line complexity
|
|
81
|
-
function
|
|
82
|
-
|
|
83
|
-
|
|
95
|
+
function paint(
|
|
96
|
+
context: CanvasRenderingContext2D,
|
|
97
|
+
zoom: number,
|
|
84
98
|
fill: string,
|
|
99
|
+
originX: number,
|
|
100
|
+
originY: number,
|
|
101
|
+
rects: Rect[],
|
|
85
102
|
) {
|
|
86
|
-
const context = element.getContext("2d");
|
|
87
|
-
if (!context) return;
|
|
88
|
-
context.setTransform(1, 0, 0, 1, 0, 0);
|
|
89
|
-
context.clearRect(0, 0, element.width, element.height);
|
|
90
|
-
|
|
91
103
|
for (let level = 0; level < LEVELS; level++) {
|
|
92
|
-
const spacing = gap * 2 ** level *
|
|
104
|
+
const spacing = gap * 2 ** level * zoom;
|
|
93
105
|
// A coarser level draws bigger dots, so zooming out reads as moving away.
|
|
94
|
-
const screenRadius = (size / 2) * 2 ** level *
|
|
106
|
+
const screenRadius = (size / 2) * 2 ** level * zoom;
|
|
95
107
|
const radius = Math.min(MAX_RADIUS, Math.max(MIN_RADIUS, screenRadius)) * ratio;
|
|
96
108
|
const last = level === LEVELS - 1;
|
|
97
109
|
const alpha = last ? 1 : smoothstep(FADE_START, FADE_END, spacing);
|
|
@@ -102,16 +114,64 @@
|
|
|
102
114
|
const pattern = context.createPattern(dotTile(tileSize, radius / scale, fill), "repeat");
|
|
103
115
|
if (!pattern) return;
|
|
104
116
|
pattern.setTransform(
|
|
105
|
-
new DOMMatrix([scale, 0, 0, scale,
|
|
117
|
+
new DOMMatrix([scale, 0, 0, scale, originX - step / 2, originY - step / 2]),
|
|
106
118
|
);
|
|
107
119
|
context.globalAlpha = alpha;
|
|
108
120
|
context.fillStyle = pattern;
|
|
109
|
-
context.fillRect(
|
|
121
|
+
for (const [x, y, w, h] of rects) context.fillRect(x, y, w, h);
|
|
110
122
|
if (alpha >= 0.999) break;
|
|
111
123
|
}
|
|
112
124
|
context.globalAlpha = 1;
|
|
113
125
|
}
|
|
114
126
|
|
|
127
|
+
/** The strips a shift of `dx`, `dy` uncovers: a column on one side and a row on the other. */
|
|
128
|
+
function uncovered(dx: number, dy: number, w: number, h: number): Rect[] {
|
|
129
|
+
const rects: Rect[] = [];
|
|
130
|
+
if (dx > 0) rects.push([0, 0, dx, h]);
|
|
131
|
+
if (dx < 0) rects.push([w + dx, 0, -dx, h]);
|
|
132
|
+
if (dy > 0) rects.push([0, 0, w, dy]);
|
|
133
|
+
if (dy < 0) rects.push([0, h + dy, w, -dy]);
|
|
134
|
+
return rects;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// A pan moves the pixels already drawn and paints only the strip it uncovered; anything else repaints it all.
|
|
138
|
+
// fallow-ignore-next-line complexity
|
|
139
|
+
function draw(
|
|
140
|
+
element: HTMLCanvasElement,
|
|
141
|
+
view: { x: number; y: number; zoom: number },
|
|
142
|
+
fill: string,
|
|
143
|
+
) {
|
|
144
|
+
const context = element.getContext("2d");
|
|
145
|
+
if (!context) return;
|
|
146
|
+
const { width: w, height: h } = element;
|
|
147
|
+
const x = view.x * ratio;
|
|
148
|
+
const y = view.y * ratio;
|
|
149
|
+
const last = painted;
|
|
150
|
+
const same =
|
|
151
|
+
last &&
|
|
152
|
+
last.zoom === view.zoom &&
|
|
153
|
+
last.ratio === ratio &&
|
|
154
|
+
last.fill === fill &&
|
|
155
|
+
last.w === w &&
|
|
156
|
+
last.h === h;
|
|
157
|
+
// Whole pixels only, so the shifted dots stay sharp; the rest waits for the next pan or zoom, never past half a pixel.
|
|
158
|
+
const dx = same ? Math.round(x - last.x) : 0;
|
|
159
|
+
const dy = same ? Math.round(y - last.y) : 0;
|
|
160
|
+
if (same && Math.abs(dx) < w && Math.abs(dy) < h) {
|
|
161
|
+
if (!dx && !dy) return;
|
|
162
|
+
context.globalCompositeOperation = "copy";
|
|
163
|
+
context.drawImage(element, dx, dy);
|
|
164
|
+
context.globalCompositeOperation = "source-over";
|
|
165
|
+
painted = { ...last, x: last.x + dx, y: last.y + dy };
|
|
166
|
+
paint(context, view.zoom, fill, painted.x, painted.y, uncovered(dx, dy, w, h));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
context.setTransform(1, 0, 0, 1, 0, 0);
|
|
170
|
+
context.clearRect(0, 0, w, h);
|
|
171
|
+
painted = { zoom: view.zoom, ratio, fill, w, h, x, y };
|
|
172
|
+
paint(context, view.zoom, fill, x, y, [[0, 0, w, h]]);
|
|
173
|
+
}
|
|
174
|
+
|
|
115
175
|
// =========================
|
|
116
176
|
// EFFECTS
|
|
117
177
|
// =========================
|
|
@@ -43,6 +43,37 @@
|
|
|
43
43
|
transform: translateX(-50%);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/* A phone has no room for a side rail: at 390px a rail on the left covers the
|
|
47
|
+
board xyflow just fitted. The rail lies flat along the bottom edge instead,
|
|
48
|
+
with its style bar stacked above it. */
|
|
49
|
+
@media (max-width: 639px) {
|
|
50
|
+
.svelte-flow .svelte-flow__panel.cv-dock.left.center {
|
|
51
|
+
top: auto;
|
|
52
|
+
bottom: 0;
|
|
53
|
+
left: 50%;
|
|
54
|
+
transform: translateX(-50%);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.cv-dock > .cv-row {
|
|
58
|
+
flex-direction: column-reverse;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.cv-dock .cv-rail {
|
|
62
|
+
flex-direction: row;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* xyflow's credit is #999 on a half-white wash, which turns grey on grey on a
|
|
67
|
+
dark board. It takes the chrome's surface and muted ink in both themes. */
|
|
68
|
+
.svelte-flow .svelte-flow__attribution {
|
|
69
|
+
border-top-left-radius: var(--radius-sm, 4px);
|
|
70
|
+
background: var(--cv-surface);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.svelte-flow .svelte-flow__attribution a {
|
|
74
|
+
color: var(--cv-ink-muted);
|
|
75
|
+
}
|
|
76
|
+
|
|
46
77
|
/* Beside the selection: StylePanel writes the spot, and a phone keeps its own row above the pill. */
|
|
47
78
|
@media (min-width: 640px) {
|
|
48
79
|
.svelte-flow .svelte-flow__panel.cv-dock.cv-style-dock.is-placed {
|
|
@@ -115,7 +146,7 @@
|
|
|
115
146
|
}
|
|
116
147
|
|
|
117
148
|
.cv-btn:active:not(:disabled) {
|
|
118
|
-
scale: 0.
|
|
149
|
+
scale: 0.96;
|
|
119
150
|
}
|
|
120
151
|
|
|
121
152
|
/* One active style for a tool, a size, a brush and an open popover. */
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
xKey,
|
|
71
71
|
yKey,
|
|
72
72
|
series: seriesInput,
|
|
73
|
-
color = "var(--chart-1)",
|
|
73
|
+
color = "var(--chart-series-1)",
|
|
74
74
|
height = 240,
|
|
75
75
|
stacked = false,
|
|
76
76
|
smooth = true,
|
|
@@ -119,7 +119,7 @@
|
|
|
119
119
|
? seriesInput.map((s, i) => ({
|
|
120
120
|
key: s.key,
|
|
121
121
|
label: s.label ?? s.key,
|
|
122
|
-
color: s.color ?? `var(--chart-${(i %
|
|
122
|
+
color: s.color ?? `var(--chart-series-${(i % 6) + 1})`,
|
|
123
123
|
}))
|
|
124
124
|
: yKey
|
|
125
125
|
? [{ key: yKey, label: yKey, color }]
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
xKey,
|
|
56
56
|
yKey,
|
|
57
57
|
series,
|
|
58
|
-
color = "var(--chart-1)",
|
|
58
|
+
color = "var(--chart-series-1)",
|
|
59
59
|
colorOf,
|
|
60
60
|
height = 240,
|
|
61
61
|
orientation = "vertical",
|
|
@@ -161,7 +161,7 @@
|
|
|
161
161
|
{#if showFigure}
|
|
162
162
|
<Figure
|
|
163
163
|
value={active ? valueOf(active) : total}
|
|
164
|
-
color="var(--chart-1)"
|
|
164
|
+
color="var(--chart-series-1)"
|
|
165
165
|
eyebrow={active ? formatX(active[xKey]) : "Total"}
|
|
166
166
|
format={formatValue}
|
|
167
167
|
size="md"
|
|
@@ -16,6 +16,25 @@
|
|
|
16
16
|
.hr-ring,
|
|
17
17
|
.hr-gauge
|
|
18
18
|
) {
|
|
19
|
+
/* The categorical series palette. The brand leads, then hues spaced round the
|
|
20
|
+
wheel so neighbours never share a family (blue and orange first, the pair that
|
|
21
|
+
survives colour blindness). Each hue is a cat token pulled 15% toward the text
|
|
22
|
+
colour: darker on a light card, lighter on a dark one, so every series clears
|
|
23
|
+
3:1 against the card in both themes (lowest: teal, 3.6:1 light, 6.2:1 dark). */
|
|
24
|
+
--chart-series-1: var(--color-brand, oklch(0.28 0 0));
|
|
25
|
+
--chart-series-2: color-mix(in oklab, var(--color-cat-blue) 85%, var(--color-foreground));
|
|
26
|
+
--chart-series-3: color-mix(in oklab, var(--color-cat-orange) 85%, var(--color-foreground));
|
|
27
|
+
--chart-series-4: color-mix(in oklab, var(--color-cat-green) 85%, var(--color-foreground));
|
|
28
|
+
--chart-series-5: color-mix(in oklab, var(--color-cat-pink) 85%, var(--color-foreground));
|
|
29
|
+
--chart-series-6: color-mix(in oklab, var(--color-cat-teal) 85%, var(--color-foreground));
|
|
30
|
+
/* The palette's `--chart-N` names point here inside a chart, so a series passed as
|
|
31
|
+
`var(--chart-3)` gets a hue, not the brand's grey highlight. */
|
|
32
|
+
--chart-1: var(--chart-series-1);
|
|
33
|
+
--chart-2: var(--chart-series-2);
|
|
34
|
+
--chart-3: var(--chart-series-3);
|
|
35
|
+
--chart-4: var(--chart-series-4);
|
|
36
|
+
--chart-5: var(--chart-series-5);
|
|
37
|
+
--chart-6: var(--chart-series-6);
|
|
19
38
|
--chart-label: var(--muted-foreground, oklch(0.5 0.01 286));
|
|
20
39
|
--chart-grid: color-mix(in srgb, var(--border, oklch(0.5 0.01 286)) 50%, transparent);
|
|
21
40
|
--chart-ease-out: var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
|
|
@@ -130,7 +130,7 @@
|
|
|
130
130
|
cornerRadius={3}
|
|
131
131
|
fill={colorKey
|
|
132
132
|
? String((arc.data as TData)[colorKey])
|
|
133
|
-
: `var(--chart-${(i %
|
|
133
|
+
: `var(--chart-series-${(i % 6) + 1})`}
|
|
134
134
|
class="hr-donut-arc"
|
|
135
135
|
data-hovered={isHovered ? "" : undefined}
|
|
136
136
|
style="opacity: {dimmed ? 0.4 : 1}; translate: {dx}px {dy}px"
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
xKey,
|
|
69
69
|
yKey,
|
|
70
70
|
series: seriesInput,
|
|
71
|
-
color = "var(--chart-1)",
|
|
71
|
+
color = "var(--chart-series-1)",
|
|
72
72
|
height = 240,
|
|
73
73
|
smooth = true,
|
|
74
74
|
edgeFade = true,
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
? seriesInput.map((s, i) => ({
|
|
105
105
|
key: s.key,
|
|
106
106
|
label: s.label ?? s.key,
|
|
107
|
-
color: s.color ?? `var(--chart-${(i %
|
|
107
|
+
color: s.color ?? `var(--chart-series-${(i % 6) + 1})`,
|
|
108
108
|
}))
|
|
109
109
|
: yKey
|
|
110
110
|
? [{ key: yKey, label: yKey, color }]
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
value: number;
|
|
17
17
|
/** Denominator for the progress arc. */
|
|
18
18
|
maxValue: number;
|
|
19
|
-
/** CSS color or var. Falls back to --chart-1..
|
|
19
|
+
/** CSS color or var. Falls back to --chart-series-1..6. */
|
|
20
20
|
color?: string;
|
|
21
21
|
};
|
|
22
22
|
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
index: i,
|
|
83
83
|
radius,
|
|
84
84
|
progress,
|
|
85
|
-
color: d.color ?? `var(--chart-${(i %
|
|
85
|
+
color: d.color ?? `var(--chart-series-${(i % 6) + 1})`,
|
|
86
86
|
};
|
|
87
87
|
}),
|
|
88
88
|
);
|