@plannotator/ui 0.39.0 → 0.41.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/HANDOFF.md +140 -10
- package/README.md +9 -5
- package/components/AnnotationPanel.tsx +244 -13
- package/components/CommentPopover.tsx +91 -2
- package/components/DiagramBlock.tsx +376 -0
- package/components/GraphvizBlock.tsx +22 -597
- package/components/HtmlSurfaceControls.tsx +188 -23
- package/components/ListMarker.tsx +10 -1
- package/components/MermaidBlock.tsx +17 -630
- package/components/TableOfContents.tsx +5 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
- package/components/Viewer.tsx +67 -3
- package/components/blocks/AlertBlock.tsx +7 -2
- package/components/diagram/DiagramCanvas.tsx +431 -0
- package/components/diagram/DiagramComposer.tsx +135 -0
- package/components/diagram/DiagramOverlay.tsx +215 -0
- package/components/diagram/DiagramPopout.tsx +70 -0
- package/components/diagram/DiagramSourcePane.tsx +244 -0
- package/components/diagram/DiagramViewer.tsx +276 -0
- package/components/diagram/anchorClaims.ts +71 -0
- package/components/diagram/index.ts +36 -0
- package/components/diagram/useDiagramComments.ts +341 -0
- package/components/diagram/useDiagramRender.ts +91 -0
- package/components/diagram/useDiagramSourceDraft.ts +143 -0
- package/components/diagram/useDiagramViewport.ts +156 -0
- package/components/html-viewer/HtmlViewer.tsx +32 -0
- package/components/html-viewer/bridge-script.asset.js +121 -9
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +133 -9
- package/components/html-viewer/useHtmlAnnotation.ts +44 -151
- package/hooks/useAnnotationHighlighter.ts +469 -14
- package/hooks/useLinkedDoc.ts +100 -9
- package/package.json +6 -4
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
- package/styles.css +1 -1
- package/theme.css +62 -0
- package/types.ts +5 -0
- package/utils/annotationScope.ts +159 -0
- package/utils/cssColor.ts +463 -0
- package/utils/diagram-anchor-graphviz.ts +143 -0
- package/utils/diagram-anchor.ts +401 -0
- package/utils/diagram-projection.ts +66 -0
- package/utils/diagram-render.ts +668 -0
- package/utils/graphviz.ts +93 -0
- package/utils/htmlChrome.ts +70 -5
- package/utils/htmlLinkNavigation.ts +196 -0
- package/utils/mermaid-eager.ts +13 -11
- package/utils/mermaid.ts +19 -10
- package/utils/mermaidTheme.ts +732 -0
- package/utils/parser.ts +36 -7
- package/utils/terminalToolsAnnouncement.ts +76 -0
- package/components/mermaidSvg.ts +0 -33
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small, dependency-free CSS colour toolkit for theme-derived rendering.
|
|
3
|
+
*
|
|
4
|
+
* Plannotator's palettes write their tokens as hex, `rgb()` and `oklch()`
|
|
5
|
+
* (see `packages/ui/themes/*.css`), and a browser's computed value for a
|
|
6
|
+
* colour can also come back as `oklab()`, `lab()`, `lch()`, `hsl()` or
|
|
7
|
+
* `color(srgb ...)`. Mermaid's own colour library (khroma) understands only
|
|
8
|
+
* the legacy syntaxes, so anything handed to it as a theme variable must first
|
|
9
|
+
* be reduced to an opaque hex string. That reduction, plus the perceptual
|
|
10
|
+
* mixing and the WCAG contrast arithmetic the diagram theme is built on, live
|
|
11
|
+
* here so `mermaidTheme.ts` reads as a mapping rather than as colour math.
|
|
12
|
+
*
|
|
13
|
+
* Browser-free on purpose: every function is pure and runs under plain `bun
|
|
14
|
+
* test`, which is what lets the contrast guard be unit-tested per palette.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** sRGB colour, channels 0..1, straight (non-premultiplied) alpha. */
|
|
18
|
+
export interface RgbColor {
|
|
19
|
+
r: number;
|
|
20
|
+
g: number;
|
|
21
|
+
b: number;
|
|
22
|
+
a: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v);
|
|
26
|
+
|
|
27
|
+
const NAMED_COLORS: Record<string, string> = {
|
|
28
|
+
white: '#ffffff',
|
|
29
|
+
black: '#000000',
|
|
30
|
+
red: '#ff0000',
|
|
31
|
+
green: '#008000',
|
|
32
|
+
blue: '#0000ff',
|
|
33
|
+
yellow: '#ffff00',
|
|
34
|
+
navy: '#000080',
|
|
35
|
+
grey: '#808080',
|
|
36
|
+
gray: '#808080',
|
|
37
|
+
lightgrey: '#d3d3d3',
|
|
38
|
+
lightgray: '#d3d3d3',
|
|
39
|
+
darkgrey: '#a9a9a9',
|
|
40
|
+
darkgray: '#a9a9a9',
|
|
41
|
+
silver: '#c0c0c0',
|
|
42
|
+
orange: '#ffa500',
|
|
43
|
+
purple: '#800080',
|
|
44
|
+
teal: '#008080',
|
|
45
|
+
transparent: '#00000000',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse one CSS colour value into sRGB. Returns `undefined` for anything it
|
|
50
|
+
* does not understand (a `var()` reference, `color-mix()`, an empty string),
|
|
51
|
+
* which callers treat as "token absent".
|
|
52
|
+
*
|
|
53
|
+
* Supported: `#rgb[a]`, `#rrggbb[aa]`, `rgb()`/`rgba()` (comma or space
|
|
54
|
+
* syntax, percentages, `/ alpha`), `hsl()`/`hsla()`, `oklch()`, `oklab()`,
|
|
55
|
+
* `lab()`, `lch()`, `color(srgb|srgb-linear|display-p3 ...)`, and a handful of
|
|
56
|
+
* named colours. Wide-gamut input is clipped to sRGB per channel.
|
|
57
|
+
*/
|
|
58
|
+
export function parseCssColor(input: string | null | undefined): RgbColor | undefined {
|
|
59
|
+
if (typeof input !== 'string') return undefined;
|
|
60
|
+
const value = input.trim().toLowerCase();
|
|
61
|
+
if (!value) return undefined;
|
|
62
|
+
|
|
63
|
+
if (value.startsWith('#')) return parseHex(value);
|
|
64
|
+
if (value in NAMED_COLORS) return parseHex(NAMED_COLORS[value]);
|
|
65
|
+
|
|
66
|
+
const fn = value.match(/^([a-z-]+)\((.*)\)$/s);
|
|
67
|
+
if (!fn) return undefined;
|
|
68
|
+
const name = fn[1];
|
|
69
|
+
const body = fn[2].trim();
|
|
70
|
+
|
|
71
|
+
switch (name) {
|
|
72
|
+
case 'rgb':
|
|
73
|
+
case 'rgba':
|
|
74
|
+
return parseRgbFunction(body);
|
|
75
|
+
case 'hsl':
|
|
76
|
+
case 'hsla':
|
|
77
|
+
return parseHslFunction(body);
|
|
78
|
+
case 'oklch':
|
|
79
|
+
return parseOklchFunction(body);
|
|
80
|
+
case 'oklab':
|
|
81
|
+
return parseOklabFunction(body);
|
|
82
|
+
case 'lab':
|
|
83
|
+
return parseLabFunction(body);
|
|
84
|
+
case 'lch':
|
|
85
|
+
return parseLchFunction(body);
|
|
86
|
+
case 'color':
|
|
87
|
+
return parseColorFunction(body);
|
|
88
|
+
default:
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseHex(value: string): RgbColor | undefined {
|
|
94
|
+
const hex = value.slice(1);
|
|
95
|
+
if (!/^[0-9a-f]+$/.test(hex)) return undefined;
|
|
96
|
+
let r: number;
|
|
97
|
+
let g: number;
|
|
98
|
+
let b: number;
|
|
99
|
+
let a = 255;
|
|
100
|
+
if (hex.length === 3 || hex.length === 4) {
|
|
101
|
+
r = parseInt(hex[0] + hex[0], 16);
|
|
102
|
+
g = parseInt(hex[1] + hex[1], 16);
|
|
103
|
+
b = parseInt(hex[2] + hex[2], 16);
|
|
104
|
+
if (hex.length === 4) a = parseInt(hex[3] + hex[3], 16);
|
|
105
|
+
} else if (hex.length === 6 || hex.length === 8) {
|
|
106
|
+
r = parseInt(hex.slice(0, 2), 16);
|
|
107
|
+
g = parseInt(hex.slice(2, 4), 16);
|
|
108
|
+
b = parseInt(hex.slice(4, 6), 16);
|
|
109
|
+
if (hex.length === 8) a = parseInt(hex.slice(6, 8), 16);
|
|
110
|
+
} else {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
return { r: r / 255, g: g / 255, b: b / 255, a: a / 255 };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Split a function body into channel tokens and an optional `/ alpha`. */
|
|
117
|
+
function splitChannels(body: string): { parts: string[]; alpha: string | undefined } | undefined {
|
|
118
|
+
let main = body;
|
|
119
|
+
let alpha: string | undefined;
|
|
120
|
+
const slash = body.indexOf('/');
|
|
121
|
+
if (slash >= 0) {
|
|
122
|
+
main = body.slice(0, slash);
|
|
123
|
+
alpha = body.slice(slash + 1).trim();
|
|
124
|
+
}
|
|
125
|
+
const parts = main
|
|
126
|
+
.split(/[\s,]+/)
|
|
127
|
+
.map((p) => p.trim())
|
|
128
|
+
.filter(Boolean);
|
|
129
|
+
if (alpha === undefined && parts.length === 4) {
|
|
130
|
+
// legacy `rgba(r, g, b, a)` / `hsla(h, s, l, a)`
|
|
131
|
+
alpha = parts.pop();
|
|
132
|
+
}
|
|
133
|
+
if (parts.length !== 3) return undefined;
|
|
134
|
+
return { parts, alpha };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseNumber(token: string, scale = 1): number | undefined {
|
|
138
|
+
if (token === 'none') return 0;
|
|
139
|
+
const m = token.match(/^(-?\d*\.?\d+(?:e[-+]?\d+)?)(%|deg|rad|grad|turn)?$/);
|
|
140
|
+
if (!m) return undefined;
|
|
141
|
+
const n = Number.parseFloat(m[1]);
|
|
142
|
+
if (!Number.isFinite(n)) return undefined;
|
|
143
|
+
switch (m[2]) {
|
|
144
|
+
case '%':
|
|
145
|
+
return (n / 100) * scale;
|
|
146
|
+
case 'rad':
|
|
147
|
+
return (n * 180) / Math.PI;
|
|
148
|
+
case 'grad':
|
|
149
|
+
return n * 0.9;
|
|
150
|
+
case 'turn':
|
|
151
|
+
return n * 360;
|
|
152
|
+
default:
|
|
153
|
+
return n;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseAlpha(token: string | undefined): number | undefined {
|
|
158
|
+
if (token === undefined) return 1;
|
|
159
|
+
const a = parseNumber(token, 1);
|
|
160
|
+
return a === undefined ? undefined : clamp01(a);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseRgbFunction(body: string): RgbColor | undefined {
|
|
164
|
+
const split = splitChannels(body);
|
|
165
|
+
if (!split) return undefined;
|
|
166
|
+
const ch = split.parts.map((p) => (p.endsWith('%') ? parseNumber(p, 1) : (parseNumber(p) ?? NaN) / 255));
|
|
167
|
+
const a = parseAlpha(split.alpha);
|
|
168
|
+
if (ch.some((v) => v === undefined || Number.isNaN(v)) || a === undefined) return undefined;
|
|
169
|
+
return { r: clamp01(ch[0] as number), g: clamp01(ch[1] as number), b: clamp01(ch[2] as number), a };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseHslFunction(body: string): RgbColor | undefined {
|
|
173
|
+
const split = splitChannels(body);
|
|
174
|
+
if (!split) return undefined;
|
|
175
|
+
const h = parseNumber(split.parts[0]);
|
|
176
|
+
const s = parseNumber(split.parts[1], 1);
|
|
177
|
+
const l = parseNumber(split.parts[2], 1);
|
|
178
|
+
const a = parseAlpha(split.alpha);
|
|
179
|
+
if (h === undefined || s === undefined || l === undefined || a === undefined) return undefined;
|
|
180
|
+
const { r, g, b } = hslToRgb(((h % 360) + 360) % 360, clamp01(s), clamp01(l));
|
|
181
|
+
return { r, g, b, a };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
|
|
185
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
186
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
187
|
+
const m = l - c / 2;
|
|
188
|
+
let rgb: [number, number, number];
|
|
189
|
+
if (h < 60) rgb = [c, x, 0];
|
|
190
|
+
else if (h < 120) rgb = [x, c, 0];
|
|
191
|
+
else if (h < 180) rgb = [0, c, x];
|
|
192
|
+
else if (h < 240) rgb = [0, x, c];
|
|
193
|
+
else if (h < 300) rgb = [x, 0, c];
|
|
194
|
+
else rgb = [c, 0, x];
|
|
195
|
+
return { r: rgb[0] + m, g: rgb[1] + m, b: rgb[2] + m };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function parseOklchFunction(body: string): RgbColor | undefined {
|
|
199
|
+
const split = splitChannels(body);
|
|
200
|
+
if (!split) return undefined;
|
|
201
|
+
const L = parseNumber(split.parts[0], 1);
|
|
202
|
+
const C = parseNumber(split.parts[1], 0.4);
|
|
203
|
+
const H = parseNumber(split.parts[2]);
|
|
204
|
+
const a = parseAlpha(split.alpha);
|
|
205
|
+
if (L === undefined || C === undefined || H === undefined || a === undefined) return undefined;
|
|
206
|
+
return { ...oklchToRgb({ L, C, H }), a };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseOklabFunction(body: string): RgbColor | undefined {
|
|
210
|
+
const split = splitChannels(body);
|
|
211
|
+
if (!split) return undefined;
|
|
212
|
+
const L = parseNumber(split.parts[0], 1);
|
|
213
|
+
const aa = parseNumber(split.parts[1], 0.4);
|
|
214
|
+
const bb = parseNumber(split.parts[2], 0.4);
|
|
215
|
+
const a = parseAlpha(split.alpha);
|
|
216
|
+
if (L === undefined || aa === undefined || bb === undefined || a === undefined) return undefined;
|
|
217
|
+
return { ...oklabToRgb({ L, a: aa, b: bb }), a };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function parseLabFunction(body: string): RgbColor | undefined {
|
|
221
|
+
const split = splitChannels(body);
|
|
222
|
+
if (!split) return undefined;
|
|
223
|
+
const L = parseNumber(split.parts[0], 100);
|
|
224
|
+
const aa = parseNumber(split.parts[1], 125);
|
|
225
|
+
const bb = parseNumber(split.parts[2], 125);
|
|
226
|
+
const a = parseAlpha(split.alpha);
|
|
227
|
+
if (L === undefined || aa === undefined || bb === undefined || a === undefined) return undefined;
|
|
228
|
+
return { ...xyzToRgb(labToXyz(L, aa, bb)), a };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function parseLchFunction(body: string): RgbColor | undefined {
|
|
232
|
+
const split = splitChannels(body);
|
|
233
|
+
if (!split) return undefined;
|
|
234
|
+
const L = parseNumber(split.parts[0], 100);
|
|
235
|
+
const C = parseNumber(split.parts[1], 150);
|
|
236
|
+
const H = parseNumber(split.parts[2]);
|
|
237
|
+
const a = parseAlpha(split.alpha);
|
|
238
|
+
if (L === undefined || C === undefined || H === undefined || a === undefined) return undefined;
|
|
239
|
+
const rad = (H * Math.PI) / 180;
|
|
240
|
+
return { ...xyzToRgb(labToXyz(L, C * Math.cos(rad), C * Math.sin(rad))), a };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function parseColorFunction(body: string): RgbColor | undefined {
|
|
244
|
+
const m = body.match(/^([a-z0-9-]+)\s+(.*)$/s);
|
|
245
|
+
if (!m) return undefined;
|
|
246
|
+
const space = m[1];
|
|
247
|
+
const split = splitChannels(m[2]);
|
|
248
|
+
if (!split) return undefined;
|
|
249
|
+
const ch = split.parts.map((p) => parseNumber(p, 1));
|
|
250
|
+
const a = parseAlpha(split.alpha);
|
|
251
|
+
if (ch.some((v) => v === undefined) || a === undefined) return undefined;
|
|
252
|
+
const [x, y, z] = ch as [number, number, number];
|
|
253
|
+
switch (space) {
|
|
254
|
+
case 'srgb':
|
|
255
|
+
return { r: clamp01(x), g: clamp01(y), b: clamp01(z), a };
|
|
256
|
+
case 'srgb-linear':
|
|
257
|
+
return { r: clamp01(linearToSrgb(x)), g: clamp01(linearToSrgb(y)), b: clamp01(linearToSrgb(z)), a };
|
|
258
|
+
case 'display-p3': {
|
|
259
|
+
const lin = [srgbToLinear(x), srgbToLinear(y), srgbToLinear(z)];
|
|
260
|
+
// display-p3 (linear) -> XYZ D65
|
|
261
|
+
const X = 0.4865709 * lin[0] + 0.2656677 * lin[1] + 0.1982173 * lin[2];
|
|
262
|
+
const Y = 0.2289746 * lin[0] + 0.6917385 * lin[1] + 0.0792869 * lin[2];
|
|
263
|
+
const Z = 0.0 * lin[0] + 0.0451134 * lin[1] + 1.0439444 * lin[2];
|
|
264
|
+
return { ...xyzToRgb({ X, Y, Z }), a };
|
|
265
|
+
}
|
|
266
|
+
default:
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Colour spaces
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
export function srgbToLinear(c: number): number {
|
|
276
|
+
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function linearToSrgb(c: number): number {
|
|
280
|
+
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
interface Xyz {
|
|
284
|
+
X: number;
|
|
285
|
+
Y: number;
|
|
286
|
+
Z: number;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function xyzToRgb({ X, Y, Z }: Xyz): { r: number; g: number; b: number } {
|
|
290
|
+
const rl = 3.2404542 * X - 1.5371385 * Y - 0.4985314 * Z;
|
|
291
|
+
const gl = -0.969266 * X + 1.8760108 * Y + 0.041556 * Z;
|
|
292
|
+
const bl = 0.0556434 * X - 0.2040259 * Y + 1.0572252 * Z;
|
|
293
|
+
return { r: clamp01(linearToSrgb(rl)), g: clamp01(linearToSrgb(gl)), b: clamp01(linearToSrgb(bl)) };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** CIE Lab (D50 white as CSS specifies) -> XYZ D65 via Bradford. */
|
|
297
|
+
function labToXyz(L: number, a: number, b: number): Xyz {
|
|
298
|
+
const fy = (L + 16) / 116;
|
|
299
|
+
const fx = fy + a / 500;
|
|
300
|
+
const fz = fy - b / 200;
|
|
301
|
+
const e = 216 / 24389;
|
|
302
|
+
const k = 24389 / 27;
|
|
303
|
+
const xr = Math.pow(fx, 3) > e ? Math.pow(fx, 3) : (116 * fx - 16) / k;
|
|
304
|
+
const yr = L > k * e ? Math.pow((L + 16) / 116, 3) : L / k;
|
|
305
|
+
const zr = Math.pow(fz, 3) > e ? Math.pow(fz, 3) : (116 * fz - 16) / k;
|
|
306
|
+
// D50 reference white
|
|
307
|
+
const X50 = xr * 0.3457 / 0.3585;
|
|
308
|
+
const Y50 = yr;
|
|
309
|
+
const Z50 = zr * (1 - 0.3457 - 0.3585) / 0.3585;
|
|
310
|
+
// Bradford D50 -> D65
|
|
311
|
+
return {
|
|
312
|
+
X: 0.9554734 * X50 - 0.0230985 * Y50 + 0.0632593 * Z50,
|
|
313
|
+
Y: -0.0283697 * X50 + 1.0099954 * Y50 + 0.0210413 * Z50,
|
|
314
|
+
Z: 0.0123141 * X50 - 0.0205050 * Y50 + 1.3299098 * Z50,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** OKLab, the perceptual space every mix and lightness edit here is done in. */
|
|
319
|
+
export interface Oklab {
|
|
320
|
+
L: number;
|
|
321
|
+
a: number;
|
|
322
|
+
b: number;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export interface Oklch {
|
|
326
|
+
L: number;
|
|
327
|
+
C: number;
|
|
328
|
+
H: number;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function rgbToOklab({ r, g, b }: { r: number; g: number; b: number }): Oklab {
|
|
332
|
+
const lr = srgbToLinear(r);
|
|
333
|
+
const lg = srgbToLinear(g);
|
|
334
|
+
const lb = srgbToLinear(b);
|
|
335
|
+
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
|
|
336
|
+
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
|
|
337
|
+
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
|
|
338
|
+
return {
|
|
339
|
+
L: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
340
|
+
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
341
|
+
b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function oklabToRgb({ L, a, b }: Oklab): { r: number; g: number; b: number } {
|
|
346
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
347
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
348
|
+
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
|
|
349
|
+
const l = l_ * l_ * l_;
|
|
350
|
+
const m = m_ * m_ * m_;
|
|
351
|
+
const s = s_ * s_ * s_;
|
|
352
|
+
const lr = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
|
353
|
+
const lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
|
354
|
+
const lb = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
|
|
355
|
+
return { r: clamp01(linearToSrgb(lr)), g: clamp01(linearToSrgb(lg)), b: clamp01(linearToSrgb(lb)) };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function oklabToOklch({ L, a, b }: Oklab): Oklch {
|
|
359
|
+
const C = Math.sqrt(a * a + b * b);
|
|
360
|
+
let H = (Math.atan2(b, a) * 180) / Math.PI;
|
|
361
|
+
if (H < 0) H += 360;
|
|
362
|
+
return { L, C, H };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function oklchToOklab({ L, C, H }: Oklch): Oklab {
|
|
366
|
+
const rad = (H * Math.PI) / 180;
|
|
367
|
+
return { L, a: C * Math.cos(rad), b: C * Math.sin(rad) };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function oklchToRgb(c: Oklch): { r: number; g: number; b: number } {
|
|
371
|
+
return oklabToRgb(oklchToOklab(c));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function rgbToOklch(c: { r: number; g: number; b: number }): Oklch {
|
|
375
|
+
return oklabToOklch(rgbToOklab(c));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// Compositing, mixing, contrast
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
/** Alpha-composite `top` over an opaque `under` (source-over). */
|
|
383
|
+
export function compositeOver(top: RgbColor, under: RgbColor): RgbColor {
|
|
384
|
+
const a = clamp01(top.a);
|
|
385
|
+
if (a >= 1) return { r: top.r, g: top.g, b: top.b, a: 1 };
|
|
386
|
+
return {
|
|
387
|
+
r: top.r * a + under.r * (1 - a),
|
|
388
|
+
g: top.g * a + under.g * (1 - a),
|
|
389
|
+
b: top.b * a + under.b * (1 - a),
|
|
390
|
+
a: 1,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Perceptual mix in OKLab: `t = 0` is `from`, `t = 1` is `to`. Alpha ignored. */
|
|
395
|
+
export function mixOklab(from: RgbColor, to: RgbColor, t: number): RgbColor {
|
|
396
|
+
const k = clamp01(t);
|
|
397
|
+
const a = rgbToOklab(from);
|
|
398
|
+
const b = rgbToOklab(to);
|
|
399
|
+
const rgb = oklabToRgb({ L: a.L + (b.L - a.L) * k, a: a.a + (b.a - a.a) * k, b: a.b + (b.b - a.b) * k });
|
|
400
|
+
return { ...rgb, a: 1 };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** WCAG relative luminance of an opaque sRGB colour. */
|
|
404
|
+
export function relativeLuminance({ r, g, b }: { r: number; g: number; b: number }): number {
|
|
405
|
+
return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** WCAG 2.x contrast ratio between two opaque colours (1..21). */
|
|
409
|
+
export function contrastRatio(a: { r: number; g: number; b: number }, b: { r: number; g: number; b: number }): number {
|
|
410
|
+
const la = relativeLuminance(a);
|
|
411
|
+
const lb = relativeLuminance(b);
|
|
412
|
+
const light = Math.max(la, lb);
|
|
413
|
+
const dark = Math.min(la, lb);
|
|
414
|
+
return (light + 0.05) / (dark + 0.05);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Round to the 8-bit sRGB grid a hex string carries, so a contrast measured
|
|
418
|
+
* here equals the contrast of the colour Mermaid actually receives. */
|
|
419
|
+
export function quantize(color: RgbColor): RgbColor {
|
|
420
|
+
const q = (v: number) => Math.round(clamp01(v) * 255) / 255;
|
|
421
|
+
return { r: q(color.r), g: q(color.g), b: q(color.b), a: 1 };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** `#rrggbb` (alpha dropped; callers composite first when it matters). */
|
|
425
|
+
export function toHex({ r, g, b }: { r: number; g: number; b: number }): string {
|
|
426
|
+
const h = (v: number) =>
|
|
427
|
+
Math.round(clamp01(v) * 255)
|
|
428
|
+
.toString(16)
|
|
429
|
+
.padStart(2, '0');
|
|
430
|
+
return `#${h(r)}${h(g)}${h(b)}`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Parse, then composite any alpha over `backdrop` so the result is opaque. */
|
|
434
|
+
export function parseOpaqueColor(value: string | null | undefined, backdrop: RgbColor): RgbColor | undefined {
|
|
435
|
+
const parsed = parseCssColor(value);
|
|
436
|
+
if (!parsed) return undefined;
|
|
437
|
+
return compositeOver(parsed, backdrop);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Return a copy of `color` with its OKLCH lightness set to `L` (0..1). */
|
|
441
|
+
export function withOklchLightness(color: RgbColor, L: number): RgbColor {
|
|
442
|
+
const lch = rgbToOklch(color);
|
|
443
|
+
return { ...oklchToRgb({ ...lch, L: clamp01(L) }), a: 1 };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Return a copy of `color` with its OKLCH chroma capped at `maxC`. */
|
|
447
|
+
export function withMaxChroma(color: RgbColor, maxC: number): RgbColor {
|
|
448
|
+
const lch = rgbToOklch(color);
|
|
449
|
+
if (lch.C <= maxC) return { ...color, a: 1 };
|
|
450
|
+
return { ...oklchToRgb({ ...lch, C: maxC }), a: 1 };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Rotate hue by `deg` in OKLCH. */
|
|
454
|
+
export function rotateHue(color: RgbColor, deg: number): RgbColor {
|
|
455
|
+
const lch = rgbToOklch(color);
|
|
456
|
+
return { ...oklchToRgb({ ...lch, H: (((lch.H + deg) % 360) + 360) % 360 }), a: 1 };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Smallest angular distance between two hues (degrees, 0..180). */
|
|
460
|
+
export function hueDistance(a: number, b: number): number {
|
|
461
|
+
const d = Math.abs(((a - b) % 360) + 360) % 360;
|
|
462
|
+
return d > 180 ? 360 - d : d;
|
|
463
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Graphviz finder: the ONE function that owns the id grammar of the
|
|
3
|
+
* svg `@viz-js/viz` emits, beside the Mermaid codec in `diagram-anchor.ts`.
|
|
4
|
+
* The anchor it writes is the same `DiagramAnchor` shape, family `graphviz`:
|
|
5
|
+
*
|
|
6
|
+
* { family: "graphviz", kind: "node", id: "Validate", label: "Validate" }
|
|
7
|
+
* { family: "graphviz", kind: "edge", from: "Order", to: "Validate", label: "" }
|
|
8
|
+
* { family: "graphviz", kind: "cluster", id: "cluster_0", label: "group" }
|
|
9
|
+
*
|
|
10
|
+
* Graphviz writes every part as `<g id="nodeN" class="node"><title>NAME
|
|
11
|
+
* </title>...`, `<g id="edgeN" class="edge"><title>A->B</title>` and
|
|
12
|
+
* `<g id="clustN" class="cluster"><title>cluster_0</title>`. The `nodeN`,
|
|
13
|
+
* `edgeN` and `clustN` ids are declaration-order counters and shift when a
|
|
14
|
+
* part is inserted mid-source (inserting `A -> Z` before `B -> C` moves C
|
|
15
|
+
* from node3 to node4), so the anchor is the DOT NAME from the group's
|
|
16
|
+
* `<title>`, never the id. Edges carry both ends in the title (`A->B` in a
|
|
17
|
+
* digraph, `A--B` in a graph); a port stays on its end (`A:p`) exactly as
|
|
18
|
+
* the title spells it. Restore order is the codec's: (1) the part whose
|
|
19
|
+
* name matches, (2) a node whose label text equals the stored label, (3)
|
|
20
|
+
* the source line as a gutter mark (the pane's job), (4) none: unanchored
|
|
21
|
+
* but listed.
|
|
22
|
+
*
|
|
23
|
+
* Pure: no React, no DOM globals beyond the Element the caller hands in.
|
|
24
|
+
*/
|
|
25
|
+
import { diagramWholeSourceLines, lineMentions, sameTarget, type DiagramTarget } from '@plannotator/core/diagram-anchor';
|
|
26
|
+
import type { DiagramFinder } from './diagram-anchor';
|
|
27
|
+
|
|
28
|
+
/** The selector of every element the pointer can address in a Graphviz svg. */
|
|
29
|
+
export const GRAPHVIZ_TARGET_SELECTOR = 'g.node, g.edge, g.cluster';
|
|
30
|
+
|
|
31
|
+
/** The group's own `<title>` (a direct child; nested groups carry their
|
|
32
|
+
* own), which is the DOT name of the part. Entities (`->`) come back
|
|
33
|
+
* decoded by the parser. */
|
|
34
|
+
function titleOf(el: Element): string | null {
|
|
35
|
+
for (const child of Array.from(el.children)) {
|
|
36
|
+
if (child.tagName.toLowerCase() === 'title') {
|
|
37
|
+
return (child.textContent ?? '').trim();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The text the part shows: every `<text>` under it, in order. A node
|
|
44
|
+
* with no label attribute shows its name; a record label shows its fields. */
|
|
45
|
+
function labelOf(el: Element): string {
|
|
46
|
+
return Array.from(el.querySelectorAll('text'))
|
|
47
|
+
.map((text) => text.textContent ?? '')
|
|
48
|
+
.join(' ')
|
|
49
|
+
.replace(/\s+/gu, ' ')
|
|
50
|
+
.trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** An edge title is `tail->head` (digraph) or `tail--head` (graph). Split
|
|
54
|
+
* at the FIRST operator: an unquoted DOT id never contains one, and a
|
|
55
|
+
* quoted name that does is not worth a grammar of its own. */
|
|
56
|
+
function splitEdgeTitle(title: string): { from: string; to: string } | null {
|
|
57
|
+
const m = /^(.*?)(?:->|--)(.*)$/u.exec(title);
|
|
58
|
+
if (m === null) return null;
|
|
59
|
+
const from = (m[1] ?? '').trim();
|
|
60
|
+
const to = (m[2] ?? '').trim();
|
|
61
|
+
if (from === '' || to === '') return null;
|
|
62
|
+
return { from, to };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Describe the rendered element under the pointer as a target, or null when
|
|
67
|
+
* it is not one of the three groups (the graph root, the background, a
|
|
68
|
+
* label group). `renderId` is unused: Graphviz ids carry no prefix and the
|
|
69
|
+
* finder never reads them.
|
|
70
|
+
*/
|
|
71
|
+
export function graphvizTargetFromElement(_svg: Element, el: Element): DiagramTarget | null {
|
|
72
|
+
const title = titleOf(el);
|
|
73
|
+
if (title === null || title === '') return null;
|
|
74
|
+
if (el.classList.contains('node')) {
|
|
75
|
+
return { family: 'graphviz', kind: 'node', id: title, label: labelOf(el) };
|
|
76
|
+
}
|
|
77
|
+
if (el.classList.contains('edge')) {
|
|
78
|
+
const ends = splitEdgeTitle(title);
|
|
79
|
+
if (ends === null) return null;
|
|
80
|
+
return { family: 'graphviz', kind: 'edge', ...ends, label: labelOf(el) };
|
|
81
|
+
}
|
|
82
|
+
if (el.classList.contains('cluster')) {
|
|
83
|
+
return { family: 'graphviz', kind: 'cluster', id: title, label: labelOf(el) };
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Step (1): the group whose title names the target. Step (2): a node whose
|
|
90
|
+
* label equals the stored label. Null when neither holds.
|
|
91
|
+
*/
|
|
92
|
+
export function graphvizFindTarget(svg: Element, target: DiagramTarget): Element | null {
|
|
93
|
+
if (target.kind === 'diagram') return svg;
|
|
94
|
+
for (const el of Array.from(svg.querySelectorAll(GRAPHVIZ_TARGET_SELECTOR))) {
|
|
95
|
+
const candidate = graphvizTargetFromElement(svg, el);
|
|
96
|
+
if (candidate !== null && sameTarget(candidate, target)) return el;
|
|
97
|
+
}
|
|
98
|
+
if (target.kind === 'node' && target.label !== '') {
|
|
99
|
+
// Only while the label names ONE node (see the Mermaid finder).
|
|
100
|
+
const matches = Array.from(svg.querySelectorAll('g.node')).filter((el) => labelOf(el) === target.label);
|
|
101
|
+
if (matches.length === 1) return matches[0] ?? null;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The 1-based line range that declares the part: DOT statements are one
|
|
108
|
+
* per line in the common case, so a node is the first line that names it
|
|
109
|
+
* as a whole token (its declaration, or the first edge that mentions it),
|
|
110
|
+
* an edge the first line that names both ends, a cluster its `subgraph`
|
|
111
|
+
* line. A quoted name (`"my node"`) matches inside its quotes. Null when
|
|
112
|
+
* the part is not found as a token, which is what a node that exists only
|
|
113
|
+
* in an unsaved draft reads as until it is saved.
|
|
114
|
+
*/
|
|
115
|
+
export function graphvizSourceLine(source: string, target: DiagramTarget): readonly [number, number] | null {
|
|
116
|
+
if (target.kind === 'diagram') return diagramWholeSourceLines(source);
|
|
117
|
+
const lines = source.split('\n');
|
|
118
|
+
const matches = (line: string): boolean => {
|
|
119
|
+
if (target.kind === 'edge') {
|
|
120
|
+
return (
|
|
121
|
+
target.from !== undefined &&
|
|
122
|
+
target.to !== undefined &&
|
|
123
|
+
lineMentions(line, target.from) &&
|
|
124
|
+
lineMentions(line, target.to)
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (target.id === undefined) return false;
|
|
128
|
+
if (target.kind === 'cluster') {
|
|
129
|
+
return /\bsubgraph\b/u.test(line) && lineMentions(line, target.id);
|
|
130
|
+
}
|
|
131
|
+
return lineMentions(line, target.id);
|
|
132
|
+
};
|
|
133
|
+
const index = lines.findIndex(matches);
|
|
134
|
+
return index === -1 ? null : [index + 1, index + 1];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The Graphviz finder behind the renderer slot's `graphviz` entry. */
|
|
138
|
+
export const GRAPHVIZ_FINDER: DiagramFinder = {
|
|
139
|
+
targetSelector: GRAPHVIZ_TARGET_SELECTOR,
|
|
140
|
+
targetFromElement: (svg, el) => graphvizTargetFromElement(svg, el),
|
|
141
|
+
findTarget: (svg, target) => graphvizFindTarget(svg, target),
|
|
142
|
+
sourceLine: graphvizSourceLine,
|
|
143
|
+
};
|