@cfasim-ui/docs 0.7.4 → 0.7.6

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.
@@ -0,0 +1,319 @@
1
+ // Canvas rendering backend for ChoroplethMap (`renderer="canvas"`).
2
+ //
3
+ // Pure scene/draw/pick helpers, kept free of Vue and DOM lookups so they
4
+ // can be unit-tested against a recording fake context. The component owns
5
+ // all state; these functions just paint and answer point queries.
6
+
7
+ /** Matches ChoroplethMap's FocusStyle without importing from the SFC. */
8
+ export type CanvasDashStyle = "solid" | "dashed" | "dotted";
9
+
10
+ export interface SceneItem {
11
+ id: string;
12
+ path: Path2D;
13
+ fill: string;
14
+ }
15
+
16
+ export interface CanvasScene {
17
+ items: SceneItem[];
18
+ indexById: Map<string, number>;
19
+ /**
20
+ * Every feature outline concatenated into one path — features share a
21
+ * stroke color, and one native stroke call beats thousands (WebKit's
22
+ * canvas2D especially).
23
+ */
24
+ outlines: Path2D | null;
25
+ /** State-borders mesh (counties / hsas mode). */
26
+ borders: Path2D | null;
27
+ }
28
+
29
+ export interface CanvasView {
30
+ dpr: number;
31
+ /** Uniform viewBox→CSS scale (xMidYMid meet). */
32
+ meetScale: number;
33
+ /** CSS-px letterbox offsets inside the canvas box. */
34
+ offsetX: number;
35
+ offsetY: number;
36
+ zoom: { k: number; x: number; y: number };
37
+ }
38
+
39
+ export interface CanvasOverlayItem {
40
+ path: Path2D;
41
+ stroke: string;
42
+ strokeWidth?: number;
43
+ style?: CanvasDashStyle;
44
+ }
45
+
46
+ export interface CanvasHighlightItem {
47
+ stroke?: string;
48
+ strokeWidth?: number;
49
+ style?: CanvasDashStyle;
50
+ }
51
+
52
+ export interface CanvasBaseState {
53
+ strokeColor: string;
54
+ /** Base feature stroke width in CSS px (effectiveStrokeWidth). */
55
+ strokeWidth: number;
56
+ /** Resolved highlight color (light-dark() can't paint on canvas). */
57
+ highlightStroke: string;
58
+ focused: Map<string, CanvasHighlightItem>;
59
+ overlays: CanvasOverlayItem[];
60
+ }
61
+
62
+ export interface CanvasDrawState extends CanvasBaseState {
63
+ hoveredId: string | null;
64
+ }
65
+
66
+ function applyViewTransform(
67
+ ctx: CanvasRenderingContext2D,
68
+ view: CanvasView,
69
+ ): void {
70
+ const s = view.dpr * view.meetScale * view.zoom.k;
71
+ ctx.setTransform(
72
+ s,
73
+ 0,
74
+ 0,
75
+ s,
76
+ view.dpr * (view.offsetX + view.meetScale * view.zoom.x),
77
+ view.dpr * (view.offsetY + view.meetScale * view.zoom.y),
78
+ );
79
+ }
80
+
81
+ /** Line width (map units) rendering as `css` CSS px on screen. */
82
+ function lwFor(view: CanvasView): (css: number) => number {
83
+ return (css) => css / (view.meetScale * view.zoom.k || 1);
84
+ }
85
+
86
+ function applyLineDash(
87
+ ctx: CanvasRenderingContext2D,
88
+ lw: (css: number) => number,
89
+ style: CanvasDashStyle | undefined,
90
+ ): void {
91
+ if (style === "dashed") {
92
+ ctx.setLineDash([lw(8), lw(4)]);
93
+ ctx.lineCap = "butt";
94
+ } else if (style === "dotted") {
95
+ ctx.setLineDash([0, lw(5)]);
96
+ ctx.lineCap = "round";
97
+ } else {
98
+ ctx.setLineDash([]);
99
+ ctx.lineCap = "butt";
100
+ }
101
+ }
102
+
103
+ export function buildScene(
104
+ features: Array<{ id?: string | number | null }>,
105
+ pathFor: (feature: never) => string | null,
106
+ colorFor: (id: string) => string,
107
+ bordersD?: string | null,
108
+ ): CanvasScene {
109
+ const items: SceneItem[] = [];
110
+ const indexById = new Map<string, number>();
111
+ const outlines = new Path2D();
112
+ let outlineCount = 0;
113
+ for (const feat of features) {
114
+ const d = pathFor(feat as never);
115
+ if (!d) continue;
116
+ const id = String(feat.id);
117
+ const path = new Path2D(d);
118
+ indexById.set(id, items.length);
119
+ items.push({ id, path, fill: colorFor(id) });
120
+ if (typeof outlines.addPath === "function") {
121
+ outlines.addPath(path);
122
+ outlineCount++;
123
+ }
124
+ }
125
+ return {
126
+ items,
127
+ indexById,
128
+ outlines: outlineCount ? outlines : null,
129
+ borders: bordersD ? new Path2D(bordersD) : null,
130
+ };
131
+ }
132
+
133
+ function highlightOne(
134
+ ctx: CanvasRenderingContext2D,
135
+ scene: CanvasScene,
136
+ lw: (css: number) => number,
137
+ state: CanvasBaseState,
138
+ id: string,
139
+ item?: CanvasHighlightItem,
140
+ ): void {
141
+ const idx = scene.indexById.get(id);
142
+ if (idx == null) return;
143
+ const it = scene.items[idx];
144
+ // Re-filling first covers neighbors' strokes — the canvas equivalent of
145
+ // the SVG renderer's appendChild raise.
146
+ ctx.fillStyle = it.fill;
147
+ ctx.fill(it.path);
148
+ ctx.strokeStyle = item?.stroke ?? state.highlightStroke;
149
+ ctx.lineWidth = lw(item?.strokeWidth ?? state.strokeWidth + 1);
150
+ applyLineDash(ctx, lw, item?.style);
151
+ ctx.stroke(it.path);
152
+ }
153
+
154
+ /**
155
+ * Base pass internals composed by drawScene. The transform maps canonical
156
+ * viewBox coordinates to device pixels:
157
+ * `device = dpr · (letterboxOffset + meetScale · zoom(point))`. Stroke
158
+ * widths are compensated so a visual width `w` CSS px stays constant at
159
+ * any zoom, mirroring the SVG renderer's `strokeDivisor`.
160
+ */
161
+ function beginBasePass(ctx: CanvasRenderingContext2D, view: CanvasView): void {
162
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
163
+ ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
164
+ applyViewTransform(ctx, view);
165
+ ctx.lineJoin = "round";
166
+ ctx.setLineDash([]);
167
+ }
168
+
169
+ /** Fills items [from, to); returns the next index. The ctx keeps the
170
+ * transform beginBasePass applied, so slices can span multiple frames. */
171
+ function drawFillSlice(
172
+ ctx: CanvasRenderingContext2D,
173
+ scene: CanvasScene,
174
+ from: number,
175
+ to: number,
176
+ ): number {
177
+ const end = Math.min(to, scene.items.length);
178
+ for (let i = from; i < end; i++) {
179
+ ctx.fillStyle = scene.items[i].fill;
180
+ ctx.fill(scene.items[i].path);
181
+ }
182
+ return end;
183
+ }
184
+
185
+ /** Outlines, borders, focus highlights, and overlays — everything but the
186
+ * transient hover, which the display frame draws live on top of blits. */
187
+ function finishBasePass(
188
+ ctx: CanvasRenderingContext2D,
189
+ scene: CanvasScene,
190
+ view: CanvasView,
191
+ state: CanvasBaseState,
192
+ ): void {
193
+ const lw = lwFor(view);
194
+ ctx.setLineDash([]);
195
+ ctx.lineWidth = lw(state.strokeWidth);
196
+ ctx.strokeStyle = state.strokeColor;
197
+ if (scene.outlines) {
198
+ ctx.stroke(scene.outlines);
199
+ } else {
200
+ for (const item of scene.items) ctx.stroke(item.path);
201
+ }
202
+
203
+ if (scene.borders) {
204
+ ctx.lineWidth = lw(1);
205
+ ctx.stroke(scene.borders);
206
+ }
207
+
208
+ for (const [id, item] of state.focused) {
209
+ highlightOne(ctx, scene, lw, state, id, item);
210
+ }
211
+
212
+ // Overlays above the focus highlights, matching the SVG z-order.
213
+ for (const o of state.overlays) {
214
+ ctx.strokeStyle = o.stroke;
215
+ ctx.lineWidth = lw(o.strokeWidth ?? state.strokeWidth + 1.5);
216
+ applyLineDash(ctx, lw, o.style);
217
+ ctx.stroke(o.path);
218
+ }
219
+ ctx.setLineDash([]);
220
+ }
221
+
222
+ /** Live hover highlight, drawn by the display frame on top of the blitted
223
+ * base (focused features are already highlighted in the base itself). */
224
+ export function drawHoverHighlight(
225
+ ctx: CanvasRenderingContext2D,
226
+ scene: CanvasScene,
227
+ view: CanvasView,
228
+ state: CanvasBaseState,
229
+ hoveredId: string | null,
230
+ ): void {
231
+ if (!hoveredId || state.focused.has(hoveredId)) return;
232
+ applyViewTransform(ctx, view);
233
+ ctx.lineJoin = "round";
234
+ highlightOne(ctx, scene, lwFor(view), state, hoveredId);
235
+ ctx.setLineDash([]);
236
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
237
+ }
238
+
239
+ /** One-shot full repaint (composition of the passes above). */
240
+ export function drawScene(
241
+ ctx: CanvasRenderingContext2D,
242
+ scene: CanvasScene,
243
+ view: CanvasView,
244
+ state: CanvasDrawState,
245
+ ): void {
246
+ beginBasePass(ctx, view);
247
+ drawFillSlice(ctx, scene, 0, scene.items.length);
248
+ finishBasePass(ctx, scene, view, state);
249
+ drawHoverHighlight(ctx, scene, view, state, state.hoveredId);
250
+ }
251
+
252
+ /** Unique fill color for feature index `i` on the picking canvas. */
253
+ export function indexToColor(i: number): string {
254
+ const v = i + 1;
255
+ return `rgb(${(v >> 16) & 255},${(v >> 8) & 255},${v & 255})`;
256
+ }
257
+
258
+ /**
259
+ * Paints every feature in a unique index color onto `canvas` at canonical
260
+ * viewBox resolution. Rebuilt only when the geometry changes; hit-testing
261
+ * then reads one pixel per query regardless of feature count.
262
+ */
263
+ export function buildPicking(
264
+ scene: CanvasScene,
265
+ width: number,
266
+ height: number,
267
+ canvas: HTMLCanvasElement,
268
+ ): CanvasRenderingContext2D | null {
269
+ canvas.width = Math.max(1, Math.round(width));
270
+ canvas.height = Math.max(1, Math.round(height));
271
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
272
+ if (!ctx) return null;
273
+ ctx.imageSmoothingEnabled = false;
274
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
275
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
276
+ scene.items.forEach((item, i) => {
277
+ ctx.fillStyle = indexToColor(i);
278
+ ctx.fill(item.path);
279
+ });
280
+ return ctx;
281
+ }
282
+
283
+ /**
284
+ * Feature index at canonical viewBox coordinates (the caller has already
285
+ * inverted the zoom transform), or null over the background. Fill edges
286
+ * antialias into blended colors, so a candidate whose decoded index is
287
+ * implausible — or that fails a point-in-path check — falls back to a
288
+ * linear `isPointInPath` scan (rare, borders only).
289
+ */
290
+ export function pickIndexAt(
291
+ ctx: CanvasRenderingContext2D,
292
+ scene: CanvasScene,
293
+ x: number,
294
+ y: number,
295
+ ): number | null {
296
+ const px = Math.round(x);
297
+ const py = Math.round(y);
298
+ if (px < 0 || py < 0 || px >= ctx.canvas.width || py >= ctx.canvas.height) {
299
+ return null;
300
+ }
301
+ const d = ctx.getImageData(px, py, 1, 1).data;
302
+ if (d[3] === 0) return null;
303
+ const idx = ((d[0] << 16) | (d[1] << 8) | d[2]) - 1;
304
+ if (idx >= 0 && idx < scene.items.length) {
305
+ if (
306
+ typeof ctx.isPointInPath !== "function" ||
307
+ ctx.isPointInPath(scene.items[idx].path, x, y)
308
+ ) {
309
+ return idx;
310
+ }
311
+ }
312
+ // Antialiased edge pixel: verify the hard way.
313
+ if (typeof ctx.isPointInPath === "function") {
314
+ for (let i = 0; i < scene.items.length; i++) {
315
+ if (ctx.isPointInPath(scene.items[i].path, x, y)) return i;
316
+ }
317
+ }
318
+ return null;
319
+ }
@@ -39,7 +39,13 @@ export function placeTooltip(
39
39
  };
40
40
 
41
41
  const flip = clientX + GAP + tipW > bounds.right - PAD;
42
- const left = flip ? clientX - GAP - tipW : clientX + GAP;
42
+ const rawLeft = flip ? clientX - GAP - tipW : clientX + GAP;
43
+ // Clamp horizontally too: a left-flip near a narrow boundary's left edge
44
+ // (or a right placement in a chart narrower than the tooltip) must not
45
+ // overflow the far side. The upper bound is floored to the lower one so a
46
+ // tooltip wider than the bounds still pins to the left edge.
47
+ const maxLeft = Math.max(bounds.left + PAD, bounds.right - PAD - tipW);
48
+ const left = Math.min(Math.max(rawLeft, bounds.left + PAD), maxLeft);
43
49
  const halfH = tipH / 2;
44
50
  const top = Math.min(
45
51
  Math.max(centerY, bounds.top + PAD + halfH),
package/index.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.7.4",
2
+ "version": "0.7.6",
3
3
  "package": "@cfasim-ui/docs",
4
4
  "content": {
5
5
  "components": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/docs",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {