@pie-players/pie-tool-graph 0.1.1

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,878 @@
1
+ <svelte:options
2
+ customElement={{
3
+ tag: 'pie-tool-graph',
4
+ shadow: 'none',
5
+ props: {
6
+ visible: { type: 'Boolean', attribute: 'visible' },
7
+ toolId: { type: 'String', attribute: 'tool-id' },
8
+ coordinator: { type: 'Object' }
9
+ }
10
+ }}
11
+ />
12
+
13
+ <script lang="ts">
14
+
15
+ import type { IToolCoordinator } from '@pie-players/pie-assessment-toolkit';
16
+ import { ZIndexLayer } from '@pie-players/pie-assessment-toolkit';
17
+ import ToolSettingsButton from '@pie-players/pie-players-shared/components/ToolSettingsButton.svelte';
18
+ import ToolSettingsPanel from '@pie-players/pie-players-shared/components/ToolSettingsPanel.svelte';
19
+ import { onDestroy, onMount } from 'svelte';
20
+
21
+ // Props
22
+ let {
23
+ visible = false,
24
+ toolId = 'graph',
25
+ coordinator
26
+ }: {
27
+ visible?: boolean;
28
+ toolId?: string;
29
+ coordinator?: IToolCoordinator;
30
+ } = $props();
31
+
32
+ // Check if running in browser
33
+ const isBrowser = typeof window !== 'undefined';
34
+
35
+ // Tool types (matching production implementation)
36
+ type Tool = 'selector' | 'point' | 'line' | 'delete';
37
+
38
+ // Data structures (matching production implementation)
39
+ interface Point {
40
+ id: number;
41
+ x: number; // Coordinate in the dynamic viewBox space
42
+ y: number; // Coordinate in the fixed 0-100 viewBox height
43
+ }
44
+
45
+ interface Line {
46
+ id: number;
47
+ p1Id: number;
48
+ p2Id: number;
49
+ }
50
+
51
+ interface Coordinates {
52
+ x: number;
53
+ y: number;
54
+ }
55
+
56
+ // State
57
+ let containerEl = $state<HTMLDivElement | undefined>();
58
+ let canvasWrapperEl = $state<HTMLDivElement | undefined>();
59
+ let svgCanvasEl = $state<SVGSVGElement | undefined>();
60
+ let settingsButtonEl = $state<HTMLButtonElement | undefined>();
61
+ let settingsOpen = $state(false);
62
+
63
+ // Position and size (matching production implementation defaults)
64
+ let x = $state(isBrowser ? window.innerWidth / 2 : 400);
65
+ let y = $state(isBrowser ? window.innerHeight / 2 : 300);
66
+ let width = $state(700);
67
+ let height = $state(550); // Production implementation uses 550px height
68
+
69
+ // Tool state
70
+ let currentTool = $state<Tool>('selector');
71
+ let points = $state<Point[]>([]);
72
+ let lines = $state<Line[]>([]);
73
+ let nextId = $state(1);
74
+ let gridOpacity = $state(1);
75
+ let tempLineStartPointId = $state<number | null>(null);
76
+ let draggingPointId = $state<number | null>(null);
77
+ let currentPointerPos = $state<Coordinates | null>(null);
78
+
79
+ // Track registration state
80
+ let registered = $state(false);
81
+
82
+ // Grid configuration (matching production implementation)
83
+ const MAJOR_VERTICAL_DIVISIONS = 5; // Fixed number of rows
84
+ const SUBGRID_DIVISIONS = 5; // 5x5 minor grid
85
+ const DESIRED_MAJOR_CELL_SIZE_SVG = 100 / MAJOR_VERTICAL_DIVISIONS; // 100 / 5 = 20 units
86
+ const DESIRED_MINOR_CELL_SIZE_SVG = DESIRED_MAJOR_CELL_SIZE_SVG / SUBGRID_DIVISIONS; // 20 / 5 = 4 units
87
+
88
+ // Container pixel dimensions (from ResizeObserver)
89
+ let containerPixelWidth = $state(0);
90
+ let containerPixelHeight = $state(0);
91
+
92
+ // Dynamic viewBox width (matching production implementation)
93
+ let viewBoxWidth = $derived.by(() => {
94
+ if (containerPixelHeight <= 0 || containerPixelWidth <= 0) {
95
+ return 100; // Default width until dimensions are known
96
+ }
97
+ // Calculate the vertical scaling factor: pixels per SVG unit
98
+ const scaleY = containerPixelHeight / 100; // (viewBox height is 100)
99
+ // Convert container pixel width back into SVG units using this scale
100
+ const requiredWidthSVG = containerPixelWidth / scaleY;
101
+ // Ensure a minimum width
102
+ return Math.max(100, requiredWidthSVG);
103
+ });
104
+
105
+ // Tool definitions (matching production implementation)
106
+ const tools: Array<{ name: Tool; icon: string; label: string; title: string }> = [
107
+ {
108
+ name: 'selector',
109
+ icon: 'selector',
110
+ label: 'Selector',
111
+ title: 'Selector: Click and drag points to move them or associated lines.'
112
+ },
113
+ {
114
+ name: 'point',
115
+ icon: 'point',
116
+ label: 'Point',
117
+ title: 'Point: Click on the grid to add points.'
118
+ },
119
+ {
120
+ name: 'line',
121
+ icon: 'line',
122
+ label: 'Line',
123
+ title: 'Line: Click a starting point, then an ending point to draw a line.'
124
+ },
125
+ {
126
+ name: 'delete',
127
+ icon: 'delete',
128
+ label: 'Delete',
129
+ title: 'Delete: Click on a point to delete it and any connected lines.'
130
+ }
131
+ ];
132
+
133
+ // Helper functions
134
+ function getUniqueId(): number {
135
+ return nextId++;
136
+ }
137
+
138
+ function getPointById(id: number | null): Point | undefined {
139
+ if (id === null) return undefined;
140
+ return points.find((p) => p.id === id);
141
+ }
142
+
143
+ function getSVGCoordinates(event: MouseEvent | PointerEvent): Coordinates | null {
144
+ if (!svgCanvasEl) return null;
145
+ const pt = svgCanvasEl.createSVGPoint();
146
+ pt.x = event.clientX;
147
+ pt.y = event.clientY;
148
+ const svgScreenCTM = svgCanvasEl.getScreenCTM();
149
+ if (!svgScreenCTM) return null;
150
+
151
+ try {
152
+ const transformedPt = pt.matrixTransform(svgScreenCTM.inverse());
153
+
154
+ // Clamp Y to 0-100 (fixed viewBox height)
155
+ transformedPt.y = Math.max(0, Math.min(100, transformedPt.y));
156
+ // Clamp X to 0 to current viewBoxWidth
157
+ transformedPt.x = Math.max(0, Math.min(viewBoxWidth, transformedPt.x));
158
+
159
+ return { x: transformedPt.x, y: transformedPt.y };
160
+ } catch (e) {
161
+ console.error('Error transforming point:', e);
162
+ return null;
163
+ }
164
+ }
165
+
166
+ function findNearestPoint(coords: Coordinates, threshold: number = 5): Point | null {
167
+ const thresholdSq = threshold * threshold;
168
+ let nearest: Point | null = null;
169
+ let minDistSq = Infinity;
170
+
171
+ for (const point of points) {
172
+ const dx = point.x - coords.x;
173
+ const dy = point.y - coords.y;
174
+ const distSq = dx * dx + dy * dy;
175
+
176
+ if (distSq < minDistSq && distSq < thresholdSq) {
177
+ minDistSq = distSq;
178
+ nearest = point;
179
+ }
180
+ }
181
+ return nearest;
182
+ }
183
+
184
+ function isPointHighlighted(pointId: number): boolean {
185
+ return pointId === tempLineStartPointId || pointId === draggingPointId;
186
+ }
187
+
188
+ // Computed grid lines (matching production implementation)
189
+ let gridLines = $derived.by(() => {
190
+ const lines = {
191
+ majorVertical: [] as number[],
192
+ majorHorizontal: [] as number[],
193
+ minorVertical: [] as number[],
194
+ minorHorizontal: [] as number[]
195
+ };
196
+ const currentViewBoxWidth = viewBoxWidth;
197
+
198
+ // Horizontal Lines (Fixed Y, extend across current viewBox width)
199
+ for (let i = 0; i <= MAJOR_VERTICAL_DIVISIONS; i++) {
200
+ const yPos = i * DESIRED_MAJOR_CELL_SIZE_SVG; // 0, 20, 40, 60, 80, 100
201
+ lines.majorHorizontal.push(yPos);
202
+ if (i < MAJOR_VERTICAL_DIVISIONS) {
203
+ for (let j = 1; j < SUBGRID_DIVISIONS; j++) {
204
+ lines.minorHorizontal.push(yPos + j * DESIRED_MINOR_CELL_SIZE_SVG);
205
+ }
206
+ }
207
+ }
208
+
209
+ // Vertical Lines (Fixed X spacing, up to current viewBox width)
210
+ let currentX = 0;
211
+ while (currentX <= currentViewBoxWidth) {
212
+ lines.majorVertical.push(currentX);
213
+ if (currentX < currentViewBoxWidth) {
214
+ for (let j = 1; j < SUBGRID_DIVISIONS; j++) {
215
+ const minorX = currentX + j * DESIRED_MINOR_CELL_SIZE_SVG;
216
+ if (minorX <= currentViewBoxWidth) {
217
+ lines.minorVertical.push(minorX);
218
+ } else {
219
+ break;
220
+ }
221
+ }
222
+ }
223
+ if (DESIRED_MAJOR_CELL_SIZE_SVG <= 0) break;
224
+ currentX += DESIRED_MAJOR_CELL_SIZE_SVG;
225
+ }
226
+
227
+ return lines;
228
+ });
229
+
230
+ // Event handlers
231
+ function setTool(tool: Tool) {
232
+ currentTool = tool;
233
+ tempLineStartPointId = null;
234
+ draggingPointId = null;
235
+ currentPointerPos = null;
236
+ }
237
+
238
+ function handleCanvasClick(event: MouseEvent) {
239
+ const coords = getSVGCoordinates(event);
240
+ if (!coords) return;
241
+
242
+ const nearestPoint = findNearestPoint(coords, DESIRED_MINOR_CELL_SIZE_SVG);
243
+
244
+ switch (currentTool) {
245
+ case 'point':
246
+ // Add point exactly where clicked in the current viewBox space
247
+ points = [...points, { id: getUniqueId(), x: coords.x, y: coords.y }];
248
+ break;
249
+
250
+ case 'line':
251
+ // If near an existing point, use it. Otherwise, create a new one.
252
+ const targetPoint = nearestPoint ?? { id: getUniqueId(), x: coords.x, y: coords.y };
253
+ if (!nearestPoint) {
254
+ points = [...points, targetPoint]; // Add if it's a new location
255
+ }
256
+
257
+ if (tempLineStartPointId === null) {
258
+ tempLineStartPointId = targetPoint.id;
259
+ } else {
260
+ if (tempLineStartPointId !== targetPoint.id) {
261
+ const exists = lines.some(
262
+ (l) =>
263
+ (l.p1Id === tempLineStartPointId && l.p2Id === targetPoint.id) ||
264
+ (l.p1Id === targetPoint.id && l.p2Id === tempLineStartPointId)
265
+ );
266
+ if (!exists) {
267
+ lines = [
268
+ ...lines,
269
+ {
270
+ id: getUniqueId(),
271
+ p1Id: tempLineStartPointId,
272
+ p2Id: targetPoint.id
273
+ }
274
+ ];
275
+ }
276
+ }
277
+ tempLineStartPointId = null;
278
+ currentPointerPos = null;
279
+ }
280
+ break;
281
+
282
+ case 'delete':
283
+ // Use a smaller threshold for precise deletion
284
+ const pointToDelete = findNearestPoint(coords, 2);
285
+ if (pointToDelete) {
286
+ // Remove the point
287
+ points = points.filter((p) => p.id !== pointToDelete.id);
288
+ // Remove lines connected to this point
289
+ lines = lines.filter(
290
+ (l) => l.p1Id !== pointToDelete.id && l.p2Id !== pointToDelete.id
291
+ );
292
+ }
293
+ break;
294
+
295
+ case 'selector':
296
+ tempLineStartPointId = null; // Cancel line drawing if clicking away
297
+ currentPointerPos = null;
298
+ break;
299
+ }
300
+ }
301
+
302
+ function handlePointPointerDown(pointId: number, event: PointerEvent) {
303
+ if (currentTool !== 'selector') return;
304
+ draggingPointId = pointId;
305
+ (event.target as Element).setPointerCapture(event.pointerId);
306
+ }
307
+
308
+ function handleCanvasMouseMove(event: PointerEvent) {
309
+ const coords = getSVGCoordinates(event);
310
+ if (!coords) return;
311
+ currentPointerPos = coords; // Update for temp line drawing
312
+
313
+ if (draggingPointId !== null && currentTool === 'selector') {
314
+ const point = getPointById(draggingPointId);
315
+ if (point) {
316
+ // Update stored coords (which are in the dynamic viewBox space)
317
+ point.x = coords.x;
318
+ point.y = coords.y; // Y is clamped 0-100 anyway
319
+ points = points; // Trigger reactivity
320
+ }
321
+ }
322
+ }
323
+
324
+ function handlePointerUp(event: PointerEvent) {
325
+ if (draggingPointId !== null) {
326
+ if ((event.target as Element)?.hasPointerCapture(event.pointerId)) {
327
+ (event.target as Element).releasePointerCapture(event.pointerId);
328
+ }
329
+ draggingPointId = null;
330
+ }
331
+ // Don't reset currentPointerPos if still drawing a line
332
+ if (currentTool !== 'line' || tempLineStartPointId === null) {
333
+ currentPointerPos = null;
334
+ }
335
+ }
336
+
337
+ function handleClose() {
338
+ coordinator?.hideTool(toolId);
339
+ }
340
+
341
+ function toggleSettings() {
342
+ settingsOpen = !settingsOpen;
343
+ }
344
+
345
+ function closeSettings() {
346
+ settingsOpen = false;
347
+ }
348
+
349
+ function handlePointerDown(e: PointerEvent) {
350
+ const target = e.target as HTMLElement;
351
+
352
+ // Only start drag if clicking the header
353
+ if (!target.closest('.graph-header')) return;
354
+
355
+ // Don't drag if clicking settings button
356
+ if (target.closest('.tool-settings-button')) return;
357
+
358
+ // Start dragging (we'll handle position updates)
359
+ if (containerEl) {
360
+ containerEl.setPointerCapture(e.pointerId);
361
+ const startX = e.clientX - x;
362
+ const startY = e.clientY - y;
363
+
364
+ function onMove(e: PointerEvent) {
365
+ let newX = e.clientX - startX;
366
+ let newY = e.clientY - startY;
367
+
368
+ // Keep calculator on screen
369
+ const halfWidth = (containerEl?.offsetWidth || width) / 2;
370
+ const halfHeight = (containerEl?.offsetHeight || height) / 2;
371
+
372
+ x = Math.max(halfWidth, Math.min(newX, window.innerWidth - halfWidth));
373
+ y = Math.max(halfHeight, Math.min(newY, window.innerHeight - halfHeight));
374
+ }
375
+
376
+ function onUp() {
377
+ if (containerEl) {
378
+ containerEl.releasePointerCapture(e.pointerId);
379
+ }
380
+ containerEl?.removeEventListener('pointermove', onMove);
381
+ containerEl?.removeEventListener('pointerup', onUp);
382
+ }
383
+
384
+ containerEl.addEventListener('pointermove', onMove);
385
+ containerEl.addEventListener('pointerup', onUp);
386
+ coordinator?.bringToFront(containerEl);
387
+ }
388
+ }
389
+
390
+ // ResizeObserver for dynamic viewBox width (matching production implementation)
391
+ let resizeObserver: ResizeObserver | null = null;
392
+ $effect(() => {
393
+ if (canvasWrapperEl) {
394
+ if (!resizeObserver) {
395
+ resizeObserver = new ResizeObserver((entries) => {
396
+ const entry = entries[0];
397
+ const { width: wrapperWidth, height: wrapperHeight } = entry.contentRect;
398
+
399
+ // Update pixel dimensions only if they actually changed
400
+ if (
401
+ Math.abs(containerPixelWidth - wrapperWidth) > 0.1 ||
402
+ Math.abs(containerPixelHeight - wrapperHeight) > 0.1
403
+ ) {
404
+ containerPixelWidth = wrapperWidth;
405
+ containerPixelHeight = wrapperHeight;
406
+ }
407
+ });
408
+ }
409
+ resizeObserver.observe(canvasWrapperEl);
410
+
411
+ return () => {
412
+ if (resizeObserver) {
413
+ resizeObserver.disconnect();
414
+ }
415
+ };
416
+ }
417
+ });
418
+
419
+ // Register with coordinator when it becomes available
420
+ $effect(() => {
421
+ if (coordinator && toolId && !registered) {
422
+ if (containerEl) {
423
+ coordinator.registerTool(toolId, 'Graph Tool', containerEl, ZIndexLayer.MODAL);
424
+ } else {
425
+ coordinator.registerTool(toolId, 'Graph Tool', undefined, ZIndexLayer.MODAL);
426
+ }
427
+ registered = true;
428
+ }
429
+ });
430
+
431
+ // Update element reference when container becomes available
432
+ $effect(() => {
433
+ if (coordinator && containerEl && toolId) {
434
+ coordinator.updateToolElement(toolId, containerEl);
435
+ coordinator.bringToFront(containerEl);
436
+ }
437
+ });
438
+
439
+ onMount(() => {
440
+ return () => {
441
+ if (resizeObserver) {
442
+ resizeObserver.disconnect();
443
+ resizeObserver = null;
444
+ }
445
+ if (coordinator && toolId) {
446
+ coordinator.unregisterTool(toolId);
447
+ }
448
+ };
449
+ });
450
+ </script>
451
+
452
+ {#if visible}
453
+ <div
454
+ bind:this={containerEl}
455
+ class="graph-tool-container"
456
+ role="dialog"
457
+ tabindex="-1"
458
+ aria-label="Graph Tool - Draw points and lines on a coordinate grid"
459
+ style="left: {x}px; top: {y}px; width: {width}px; height: {height}px; transform: translate(-50%, -50%);"
460
+ onpointerdown={handlePointerDown}
461
+ >
462
+ <!-- Header (matching production implementation: dark teal) -->
463
+ <div class="graph-header">
464
+ <h3 id="graph-tool-title" class="graph-title">Graph Tool</h3>
465
+ <div class="header-controls">
466
+ <ToolSettingsButton
467
+ bind:buttonEl={settingsButtonEl}
468
+ onClick={toggleSettings}
469
+ ariaLabel="Graph tool settings"
470
+ active={settingsOpen}
471
+ />
472
+ </div>
473
+ </div>
474
+
475
+ <!-- Toolbar (matching production implementation: lighter teal) -->
476
+ <div class="graph-toolbar">
477
+ <!-- Tool buttons -->
478
+ <div class="tool-buttons">
479
+ {#each tools as tool (tool.name)}
480
+ <button
481
+ type="button"
482
+ class="tool-button"
483
+ class:active={currentTool === tool.name}
484
+ onclick={() => setTool(tool.name)}
485
+ title={tool.title}
486
+ aria-label={tool.title}
487
+ aria-pressed={currentTool === tool.name}
488
+ >
489
+ <span class="tool-icon" aria-hidden="true">
490
+ {#if tool.name === 'selector'}
491
+ <!-- Selector icon (swirling arrow) -->
492
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
493
+ <path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z" />
494
+ </svg>
495
+ {:else if tool.name === 'point'}
496
+ <!-- Point icon (pushpin) -->
497
+ <svg viewBox="0 0 24 24" fill="currentColor">
498
+ <path
499
+ d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"
500
+ />
501
+ </svg>
502
+ {:else if tool.name === 'line'}
503
+ <!-- Line icon (pencil) -->
504
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
505
+ <path
506
+ d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"
507
+ />
508
+ </svg>
509
+ {:else if tool.name === 'delete'}
510
+ <!-- Delete icon (trash) -->
511
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
512
+ <path
513
+ d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
514
+ />
515
+ </svg>
516
+ {/if}
517
+ </span>
518
+ <span class="tool-label">{tool.label}</span>
519
+ </button>
520
+ {/each}
521
+ </div>
522
+
523
+ <!-- Grid opacity slider (matching production implementation) -->
524
+ <div class="transparency-control">
525
+ <label for="grid-opacity">Grid:</label>
526
+ <input
527
+ type="range"
528
+ id="grid-opacity"
529
+ min="0"
530
+ max="1"
531
+ step="0.1"
532
+ bind:value={gridOpacity}
533
+ aria-label="Grid opacity"
534
+ />
535
+ </div>
536
+ </div>
537
+
538
+ <!-- Canvas wrapper -->
539
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
540
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
541
+ <div
542
+ bind:this={canvasWrapperEl}
543
+ class="graph-canvas-wrapper"
544
+ role="img"
545
+ tabindex="0"
546
+ aria-label="Graph canvas - Use tools to add points and draw lines"
547
+ onclick={handleCanvasClick}
548
+ onkeydown={(e) => {
549
+ if (e.key === 'Enter' || e.key === ' ') {
550
+ e.preventDefault();
551
+ handleCanvasClick(e as any);
552
+ }
553
+ }}
554
+ >
555
+ <svg
556
+ bind:this={svgCanvasEl}
557
+ class="graph-canvas"
558
+ viewBox="0 0 {viewBoxWidth} 100"
559
+ preserveAspectRatio="xMinYMin meet"
560
+ aria-hidden="true"
561
+ onpointermove={handleCanvasMouseMove}
562
+ onpointerup={handlePointerUp}
563
+ onpointerleave={handlePointerUp}
564
+ >
565
+ <!-- Grid Lines -->
566
+ <g class="grid-lines" style="opacity: {gridOpacity}" aria-hidden="true">
567
+ <!-- Minor Horizontal Lines -->
568
+ {#each gridLines.minorHorizontal as yPos, index (index)}
569
+ <line
570
+ x1="0"
571
+ y1={yPos}
572
+ x2={viewBoxWidth}
573
+ y2={yPos}
574
+ class="grid-line grid-line-minor"
575
+ />
576
+ {/each}
577
+ <!-- Major Horizontal Lines -->
578
+ {#each gridLines.majorHorizontal as yPos, index (index)}
579
+ <line
580
+ x1="0"
581
+ y1={yPos}
582
+ x2={viewBoxWidth}
583
+ y2={yPos}
584
+ class="grid-line grid-line-major"
585
+ />
586
+ {/each}
587
+
588
+ <!-- Minor Vertical Lines -->
589
+ {#each gridLines.minorVertical as xPos, index (index)}
590
+ <line
591
+ x1={xPos}
592
+ y1="0"
593
+ x2={xPos}
594
+ y2="100"
595
+ class="grid-line grid-line-minor"
596
+ />
597
+ {/each}
598
+ <!-- Major Vertical Lines -->
599
+ {#each gridLines.majorVertical as xPos, index (index)}
600
+ <line
601
+ x1={xPos}
602
+ y1="0"
603
+ x2={xPos}
604
+ y2="100"
605
+ class="grid-line grid-line-major"
606
+ />
607
+ {/each}
608
+ </g>
609
+
610
+ <!-- Lines -->
611
+ <g class="lines">
612
+ {#each lines as line (line.id)}
613
+ {@const p1 = getPointById(line.p1Id)}
614
+ {@const p2 = getPointById(line.p2Id)}
615
+ {#if p1 && p2}
616
+ <line
617
+ x1={p1.x}
618
+ y1={p1.y}
619
+ x2={p2.x}
620
+ y2={p2.y}
621
+ class="user-line"
622
+ />
623
+ {/if}
624
+ {/each}
625
+ </g>
626
+
627
+ <!-- Points -->
628
+ <g class="points">
629
+ {#each points as point (point.id)}
630
+ <circle
631
+ cx={point.x}
632
+ cy={point.y}
633
+ r="2"
634
+ class="user-point"
635
+ class:highlight={isPointHighlighted(point.id)}
636
+ data-id={point.id}
637
+ onpointerdown={(e) => {
638
+ e.stopPropagation();
639
+ handlePointPointerDown(point.id, e);
640
+ }}
641
+ />
642
+ {/each}
643
+ </g>
644
+
645
+ <!-- Temporary line feedback -->
646
+ {#if tempLineStartPointId && currentPointerPos}
647
+ {@const startPoint = getPointById(tempLineStartPointId)}
648
+ {#if startPoint}
649
+ <line
650
+ x1={startPoint.x}
651
+ y1={startPoint.y}
652
+ x2={currentPointerPos.x}
653
+ y2={currentPointerPos.y}
654
+ class="temp-line"
655
+ />
656
+ {/if}
657
+ {/if}
658
+ </svg>
659
+ </div>
660
+ </div>
661
+
662
+ <!-- Settings Panel -->
663
+ <ToolSettingsPanel
664
+ open={settingsOpen}
665
+ title="Graph Tool Settings"
666
+ onClose={closeSettings}
667
+ anchorEl={settingsButtonEl}
668
+ >
669
+ <fieldset class="setting-group">
670
+ <legend>Canvas</legend>
671
+ <label>
672
+ <span class="setting-label">Grid Opacity</span>
673
+ <input
674
+ type="range"
675
+ min="0"
676
+ max="1"
677
+ step="0.1"
678
+ bind:value={gridOpacity}
679
+ aria-label="Grid opacity"
680
+ />
681
+ </label>
682
+ </fieldset>
683
+ </ToolSettingsPanel>
684
+ {/if}
685
+
686
+ <style>
687
+ .graph-tool-container {
688
+ position: fixed;
689
+ background: white;
690
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
691
+ user-select: none;
692
+ touch-action: none;
693
+ border-radius: 12px;
694
+ overflow: hidden;
695
+ z-index: 2000; /* ZIndexLayer.MODAL */
696
+ min-width: 500px;
697
+ display: flex;
698
+ flex-direction: column;
699
+ }
700
+
701
+ /* Header (matching production implementation: dark teal) */
702
+ .graph-header {
703
+ padding: 12px 16px;
704
+ background: var(--pie-primary-dark, #2c3e50); /* Dark teal-like color */
705
+ color: var(--pie-white, white);
706
+ display: flex;
707
+ justify-content: space-between;
708
+ align-items: center;
709
+ cursor: move;
710
+ user-select: none;
711
+ border-radius: 12px 12px 0 0;
712
+ }
713
+
714
+ .graph-title {
715
+ font-weight: 600;
716
+ font-size: 16px;
717
+ color: var(--pie-white, white);
718
+ margin: 0;
719
+ }
720
+
721
+ .header-controls {
722
+ display: flex;
723
+ gap: 8px;
724
+ align-items: center;
725
+ }
726
+
727
+ /* Toolbar (matching production implementation: lighter teal) */
728
+ .graph-toolbar {
729
+ padding: 8px;
730
+ background: var(--pie-primary-light, #5a7fa3); /* Lighter teal-like color */
731
+ display: flex;
732
+ gap: 16px;
733
+ align-items: center;
734
+ flex-wrap: wrap;
735
+ }
736
+
737
+ .tool-buttons {
738
+ display: flex;
739
+ gap: 4px;
740
+ flex: 1;
741
+ }
742
+
743
+ .tool-button {
744
+ flex: 1;
745
+ display: flex;
746
+ flex-direction: column;
747
+ align-items: center;
748
+ gap: 4px;
749
+ padding: 8px 12px;
750
+ background: rgba(255, 255, 255, 0.2);
751
+ border: 2px solid transparent;
752
+ border-radius: 4px;
753
+ cursor: pointer;
754
+ color: white;
755
+ font-size: 12px;
756
+ transition: all 0.2s;
757
+ }
758
+
759
+ .tool-button:hover {
760
+ background: rgba(255, 255, 255, 0.3);
761
+ }
762
+
763
+ .tool-button.active {
764
+ background: white;
765
+ color: var(--pie-primary-dark, #2c3e50);
766
+ border-color: var(--pie-primary-dark, #2c3e50);
767
+ }
768
+
769
+ .tool-icon {
770
+ width: 20px;
771
+ height: 20px;
772
+ display: flex;
773
+ align-items: center;
774
+ justify-content: center;
775
+ }
776
+
777
+ .tool-label {
778
+ font-size: 11px;
779
+ font-weight: 500;
780
+ }
781
+
782
+ .transparency-control {
783
+ display: flex;
784
+ align-items: center;
785
+ gap: 8px;
786
+ color: white;
787
+ font-size: 12px;
788
+ padding-left: 8px;
789
+ }
790
+
791
+ .transparency-control label {
792
+ font-weight: 500;
793
+ }
794
+
795
+ .transparency-control input[type='range'] {
796
+ width: 100px;
797
+ cursor: pointer;
798
+ }
799
+
800
+ /* Canvas wrapper */
801
+ .graph-canvas-wrapper {
802
+ flex: 1;
803
+ background: white;
804
+ display: flex;
805
+ align-items: center;
806
+ justify-content: center;
807
+ overflow: hidden;
808
+ }
809
+
810
+ .graph-canvas {
811
+ display: block;
812
+ width: 100%;
813
+ height: 100%;
814
+ }
815
+
816
+ /* Grid lines (matching production implementation: dark gray) */
817
+ .grid-line {
818
+ stroke: var(--pie-primary-console, #666);
819
+ vector-effect: non-scaling-stroke;
820
+ }
821
+
822
+ .grid-line-major {
823
+ stroke: var(--pie-primary-dark-console, #333);
824
+ stroke-width: 0.75;
825
+ }
826
+
827
+ .grid-line-minor {
828
+ stroke: var(--pie-primary-light-console, #ccc);
829
+ stroke-width: 0.5;
830
+ }
831
+
832
+ /* User drawing styles */
833
+ .user-point {
834
+ cursor: pointer;
835
+ fill: var(--pie-primary, #3f51b5);
836
+ stroke: var(--pie-primary-dark, #2c3e50);
837
+ stroke-width: 0.5;
838
+ vector-effect: non-scaling-stroke;
839
+ }
840
+
841
+ .user-point.highlight {
842
+ fill: var(--pie-warning, #ffc107);
843
+ stroke: var(--pie-warning-dark, #ff9800);
844
+ }
845
+
846
+ .user-line {
847
+ stroke: var(--pie-dark-gray, #333);
848
+ stroke-linecap: round;
849
+ stroke-width: 1;
850
+ vector-effect: non-scaling-stroke;
851
+ }
852
+
853
+ .temp-line {
854
+ pointer-events: none;
855
+ stroke: var(--pie-success, #4caf50);
856
+ stroke-dasharray: 2, 2;
857
+ stroke-width: 0.75;
858
+ vector-effect: non-scaling-stroke;
859
+ }
860
+
861
+ .setting-group {
862
+ border: 1px solid var(--pie-border, #ccc);
863
+ border-radius: 4px;
864
+ padding: 12px;
865
+ margin-bottom: 16px;
866
+ }
867
+
868
+ .setting-group legend {
869
+ font-weight: 600;
870
+ padding: 0 8px;
871
+ }
872
+
873
+ .setting-label {
874
+ display: block;
875
+ margin-bottom: 8px;
876
+ font-weight: 500;
877
+ }
878
+ </style>