@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.
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/tool-graph.js +3489 -0
- package/dist/tool-graph.js.map +1 -0
- package/index.ts +8 -0
- package/package.json +62 -0
- package/tool-graph.svelte +878 -0
|
@@ -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>
|