@lapis-notes/graph 0.1.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/LICENSE.md +8 -0
  3. package/README.md +20 -0
  4. package/dist/embed.d.ts +5 -0
  5. package/dist/embed.js +4 -0
  6. package/dist/graph-build-generation.d.ts +6 -0
  7. package/dist/graph-build-generation.js +13 -0
  8. package/dist/graph-controls-overlay.svelte +840 -0
  9. package/dist/graph-controls-overlay.svelte.d.ts +30 -0
  10. package/dist/graph-data-coordinator.d.ts +41 -0
  11. package/dist/graph-data-coordinator.js +265 -0
  12. package/dist/graph-data.d.ts +23 -0
  13. package/dist/graph-data.js +493 -0
  14. package/dist/graph-embed.svelte +92 -0
  15. package/dist/graph-embed.svelte.d.ts +12 -0
  16. package/dist/graph-file-preview.svelte +115 -0
  17. package/dist/graph-file-preview.svelte.d.ts +12 -0
  18. package/dist/graph-hover-intent.d.ts +22 -0
  19. package/dist/graph-hover-intent.js +85 -0
  20. package/dist/graph-load-alignment.d.ts +2 -0
  21. package/dist/graph-load-alignment.js +3 -0
  22. package/dist/graph-navigation.d.ts +7 -0
  23. package/dist/graph-navigation.js +78 -0
  24. package/dist/graph-node-activation.d.ts +4 -0
  25. package/dist/graph-node-activation.js +16 -0
  26. package/dist/graph-plugin.d.ts +38 -0
  27. package/dist/graph-plugin.js +177 -0
  28. package/dist/graph-problems.d.ts +15 -0
  29. package/dist/graph-problems.js +46 -0
  30. package/dist/graph-query-resolution.d.ts +9 -0
  31. package/dist/graph-query-resolution.js +44 -0
  32. package/dist/graph-renderer.d.ts +226 -0
  33. package/dist/graph-renderer.js +1350 -0
  34. package/dist/graph-settings.d.ts +20 -0
  35. package/dist/graph-settings.js +125 -0
  36. package/dist/graph-types.d.ts +74 -0
  37. package/dist/graph-types.js +1 -0
  38. package/dist/graph-view.d.ts +62 -0
  39. package/dist/graph-view.js +553 -0
  40. package/dist/graph-viewport-alignment.d.ts +6 -0
  41. package/dist/graph-viewport-alignment.js +9 -0
  42. package/dist/index.d.ts +9 -0
  43. package/dist/index.js +8 -0
  44. package/dist/mount-local-component.svelte.d.ts +7 -0
  45. package/dist/mount-local-component.svelte.js +12 -0
  46. package/dist/public-components.d.ts +2 -0
  47. package/dist/public-components.js +2 -0
  48. package/dist/styles.css +441 -0
  49. package/manifest.json +30 -0
  50. package/package.json +84 -0
  51. package/styles.css +1 -0
@@ -0,0 +1,840 @@
1
+ <script lang="ts">
2
+ import { searchQueryLanguageSupport, type App } from "@lapis-notes/api";
3
+ import {
4
+ SearchFilterInput,
5
+ type SearchFilterSyntax,
6
+ } from "@lapismd/design-core/filter";
7
+ import * as Accordion from "@lapismd/design-core/shadcn/accordion";
8
+ import { Button } from "@lapismd/design-core/shadcn/button";
9
+ import { Slider } from "@lapismd/design-core/shadcn/slider";
10
+ import { Switch } from "@lapismd/design-core/shadcn/switch";
11
+ import { ColorPicker, SortableArrayItem } from "@lapismd/design-core/forms";
12
+ import LocateFixed from "@lucide/svelte/icons/locate-fixed";
13
+ import Minus from "@lucide/svelte/icons/minus";
14
+ import Plus from "@lucide/svelte/icons/plus";
15
+ import RefreshCw from "@lucide/svelte/icons/refresh-cw";
16
+ import RotateCcw from "@lucide/svelte/icons/rotate-ccw";
17
+ import Settings from "@lucide/svelte/icons/settings";
18
+ import X from "@lucide/svelte/icons/x";
19
+ import { onDestroy } from "svelte";
20
+ import GraphFilePreview from "./graph-file-preview.svelte";
21
+ import type { GraphNodePreview } from "./graph-renderer";
22
+ import type {
23
+ GraphGroupRule,
24
+ GraphSettings,
25
+ GraphSettingsPatch,
26
+ } from "./graph-types";
27
+ import { moveGraphGroup } from "./graph-settings";
28
+
29
+ let {
30
+ app,
31
+ isLocal,
32
+ settings,
33
+ statsText,
34
+ statusText,
35
+ statusKind,
36
+ groupDiagnostics,
37
+ filterDiagnostic,
38
+ filterSyntax,
39
+ preview,
40
+ isAnimating,
41
+ onFocusActiveFile,
42
+ onZoomIn,
43
+ onZoomOut,
44
+ onResetView,
45
+ onRefreshGraph,
46
+ onResetDefaults,
47
+ onToggleAnimation,
48
+ onSettingsPatch,
49
+ onOpenPreviewFile,
50
+ onDismissPreview,
51
+ }: {
52
+ app: App;
53
+ isLocal: boolean;
54
+ settings: GraphSettings;
55
+ statsText: string;
56
+ statusText: string;
57
+ statusKind: "loading" | "error" | null;
58
+ groupDiagnostics: Readonly<Record<string, string>>;
59
+ filterDiagnostic: string | null;
60
+ filterSyntax: SearchFilterSyntax;
61
+ preview: GraphNodePreview | null;
62
+ isAnimating: boolean;
63
+ onFocusActiveFile: () => void;
64
+ onZoomIn: () => void;
65
+ onZoomOut: () => void;
66
+ onResetView: () => void;
67
+ onRefreshGraph: () => void;
68
+ onResetDefaults: () => void;
69
+ onToggleAnimation: () => void;
70
+ onSettingsPatch: (patch: GraphSettingsPatch) => void;
71
+ onOpenPreviewFile: (preview: GraphNodePreview, event: MouseEvent) => void;
72
+ onDismissPreview: () => void;
73
+ } = $props();
74
+
75
+ let settingsOpen = $state(false);
76
+ let openSections = $state<string[]>([]);
77
+ let draggingGroupId = $state<string | null>(null);
78
+ let stopGroupDrag: (() => void) | null = null;
79
+ const queryExtensions = [searchQueryLanguageSupport()];
80
+
81
+ function formatValue(value: number): string {
82
+ if (Number.isInteger(value)) {
83
+ return String(value);
84
+ }
85
+
86
+ const digits = Math.abs(value) < 1 ? 2 : 1;
87
+ return value
88
+ .toFixed(digits)
89
+ .replace(/\.0+$/, "")
90
+ .replace(/(\.\d*[1-9])0+$/, "$1");
91
+ }
92
+
93
+ function updateSlider(
94
+ nextValue: number,
95
+ createPatch: (value: number) => GraphSettingsPatch,
96
+ ): void {
97
+ if (Number.isFinite(nextValue)) {
98
+ onSettingsPatch(createPatch(nextValue));
99
+ }
100
+ }
101
+
102
+ const groupColors = ["#3b82f6", "#16a34a", "#d97706", "#9333ea", "#db2777"];
103
+
104
+ function updateGroups(groups: GraphGroupRule[]): void {
105
+ onSettingsPatch({ groups });
106
+ }
107
+
108
+ function updateGroup(id: string, patch: Partial<GraphGroupRule>): void {
109
+ updateGroups(
110
+ settings.groups.map((group) =>
111
+ group.id === id ? { ...group, ...patch } : group,
112
+ ),
113
+ );
114
+ }
115
+
116
+ function addGroup(): void {
117
+ const index = settings.groups.length;
118
+ const id =
119
+ typeof crypto !== "undefined" && "randomUUID" in crypto
120
+ ? crypto.randomUUID()
121
+ : `group-${Date.now()}-${index}`;
122
+ updateGroups([
123
+ ...settings.groups,
124
+ {
125
+ id,
126
+ query: "",
127
+ color: groupColors[index % groupColors.length]!,
128
+ },
129
+ ]);
130
+ }
131
+
132
+ function removeGroup(id: string): void {
133
+ updateGroups(settings.groups.filter((group) => group.id !== id));
134
+ }
135
+
136
+ function moveGroup(index: number, delta: number): void {
137
+ updateGroups(moveGraphGroup(settings.groups, index, delta));
138
+ }
139
+
140
+ function startGroupDrag(event: PointerEvent, index: number): void {
141
+ if (event.button !== 0) return;
142
+ event.preventDefault();
143
+ stopGroupDrag?.();
144
+ draggingGroupId = settings.groups[index]?.id ?? null;
145
+ const handle = event.currentTarget;
146
+ if (!(handle instanceof HTMLElement)) return;
147
+ const ownerDocument = handle.ownerDocument;
148
+ const ownerWindow = ownerDocument.defaultView;
149
+ if (!draggingGroupId || !ownerWindow) return;
150
+
151
+ const handleMove = (moveEvent: PointerEvent) => {
152
+ const target = ownerDocument
153
+ .elementFromPoint(moveEvent.clientX, moveEvent.clientY)
154
+ ?.closest<HTMLElement>(
155
+ '[data-sortable-group="graph-groups"][data-sortable-item]',
156
+ );
157
+ const targetIndex = Number(target?.dataset.sortableIndex);
158
+ const currentIndex = settings.groups.findIndex(
159
+ (group) => group.id === draggingGroupId,
160
+ );
161
+ if (
162
+ Number.isInteger(targetIndex) &&
163
+ currentIndex >= 0 &&
164
+ targetIndex !== currentIndex
165
+ ) {
166
+ moveGroup(currentIndex, targetIndex - currentIndex);
167
+ }
168
+ };
169
+ const handleEnd = () => stopGroupDrag?.();
170
+ stopGroupDrag = () => {
171
+ ownerWindow.removeEventListener("pointermove", handleMove);
172
+ ownerWindow.removeEventListener("pointerup", handleEnd);
173
+ ownerWindow.removeEventListener("pointercancel", handleEnd);
174
+ draggingGroupId = null;
175
+ stopGroupDrag = null;
176
+ };
177
+ ownerWindow.addEventListener("pointermove", handleMove);
178
+ ownerWindow.addEventListener("pointerup", handleEnd, { once: true });
179
+ ownerWindow.addEventListener("pointercancel", handleEnd, { once: true });
180
+ }
181
+
182
+ onDestroy(() => stopGroupDrag?.());
183
+
184
+ const toolbarIconStyle = "width: 12px; height: 12px";
185
+ </script>
186
+
187
+ <div
188
+ class="graph-view__layout"
189
+ data-ui-component="graph-controls"
190
+ data-ui-part="layout"
191
+ >
192
+ <div
193
+ data-graph-surface
194
+ data-ui-part="surface"
195
+ class="graph-view__surface"
196
+ ></div>
197
+
198
+ <GraphFilePreview
199
+ {app}
200
+ {preview}
201
+ closeDelayMs={settings.display.hoverReleaseDelayMs}
202
+ onOpenFile={onOpenPreviewFile}
203
+ onDismiss={onDismissPreview}
204
+ />
205
+
206
+ {#if statsText && !isLocal}
207
+ <div class="graph-view__stats" data-ui-part="stats">
208
+ <span class="graph-view__stats-badge">
209
+ {statsText}
210
+ </span>
211
+ </div>
212
+ {/if}
213
+
214
+ {#if statusText}
215
+ <div
216
+ class="graph-view__status"
217
+ data-ui-part="status"
218
+ data-state={statusKind}
219
+ role={statusKind === "error" ? "alert" : "status"}
220
+ >
221
+ {statusText}
222
+ </div>
223
+ {/if}
224
+
225
+ <div class="graph-controls-toolbar" data-ui-part="toolbar">
226
+ {#if !isLocal}
227
+ <Button
228
+ variant="ghost"
229
+ size="icon"
230
+ class="graph-controls-toolbar-button"
231
+ aria-label="Zoom in"
232
+ title="Zoom in"
233
+ onclick={onZoomIn}
234
+ >
235
+ <Plus style={toolbarIconStyle} />
236
+ </Button>
237
+
238
+ <Button
239
+ variant="ghost"
240
+ size="icon"
241
+ class="graph-controls-toolbar-button"
242
+ aria-label="Reset view"
243
+ title="Reset view"
244
+ onclick={onResetView}
245
+ >
246
+ <RotateCcw style={toolbarIconStyle} />
247
+ </Button>
248
+
249
+ <Button
250
+ variant="ghost"
251
+ size="icon"
252
+ class="graph-controls-toolbar-button"
253
+ aria-label="Zoom out"
254
+ title="Zoom out"
255
+ onclick={onZoomOut}
256
+ >
257
+ <Minus style={toolbarIconStyle} />
258
+ </Button>
259
+
260
+ <Button
261
+ variant="ghost"
262
+ size="icon"
263
+ class="graph-controls-toolbar-button"
264
+ aria-label="Focus active file"
265
+ title="Focus active file"
266
+ onclick={onFocusActiveFile}
267
+ >
268
+ <LocateFixed style={toolbarIconStyle} />
269
+ </Button>
270
+ {/if}
271
+
272
+ <Button
273
+ variant="ghost"
274
+ size="icon"
275
+ class={`graph-controls-toolbar-button${isLocal ? "" : " graph-controls-toolbar-button--last"}`}
276
+ aria-label="Toggle graph settings"
277
+ aria-expanded={settingsOpen}
278
+ aria-haspopup="dialog"
279
+ title="Toggle graph settings"
280
+ onclick={() => {
281
+ settingsOpen = !settingsOpen;
282
+ }}
283
+ >
284
+ <Settings style={toolbarIconStyle} />
285
+ </Button>
286
+ </div>
287
+
288
+ {#if settingsOpen}
289
+ <div
290
+ role="dialog"
291
+ aria-label="Graph settings"
292
+ class="graph-controls-panel"
293
+ data-ui-part="settings-panel"
294
+ >
295
+ <Accordion.Root
296
+ type="multiple"
297
+ bind:value={openSections}
298
+ class="graph-controls-sections"
299
+ >
300
+ <Accordion.Item value="filters">
301
+ <div class="graph-controls-section-header">
302
+ <Accordion.Trigger
303
+ style="height: 40px; min-height: 40px;"
304
+ indicatorPosition="start"
305
+ indicatorVariant="disclosure"
306
+ class="graph-controls-trigger graph-controls-trigger--actions"
307
+ >
308
+ <span>Filters</span>
309
+ </Accordion.Trigger>
310
+
311
+ <div class="graph-controls-trigger__actions">
312
+ <Button
313
+ variant="ghost"
314
+ size="icon"
315
+ class="graph-controls-header-button"
316
+ aria-label="Refresh graph"
317
+ title="Refresh graph"
318
+ onclick={onRefreshGraph}
319
+ >
320
+ <RefreshCw class="graph-controls-icon" />
321
+ </Button>
322
+
323
+ <Button
324
+ variant="ghost"
325
+ size="icon"
326
+ class="graph-controls-header-button"
327
+ aria-label="Reset defaults"
328
+ title="Reset defaults"
329
+ onclick={onResetDefaults}
330
+ >
331
+ <RotateCcw class="graph-controls-icon" />
332
+ </Button>
333
+
334
+ <Button
335
+ variant="ghost"
336
+ size="icon"
337
+ class="graph-controls-header-button"
338
+ aria-label="Close settings"
339
+ title="Close settings"
340
+ onclick={() => {
341
+ settingsOpen = false;
342
+ }}
343
+ >
344
+ <X class="graph-controls-icon" />
345
+ </Button>
346
+ </div>
347
+ </div>
348
+
349
+ <Accordion.Content class="graph-controls-section-content">
350
+ <div class="graph-controls-field-list">
351
+ <div>
352
+ <SearchFilterInput
353
+ placeholder="Search files"
354
+ ariaLabel="Search files"
355
+ value={settings.filters.searchQuery}
356
+ density="compact"
357
+ inputMode="filter-query"
358
+ showSearchIcon
359
+ showClearButton
360
+ error={filterDiagnostic}
361
+ editorExtensions={queryExtensions}
362
+ {filterSyntax}
363
+ onValueChange={(value) =>
364
+ onSettingsPatch({ filters: { searchQuery: value } })}
365
+ onClearSearch={() =>
366
+ onSettingsPatch({ filters: { searchQuery: "" } })}
367
+ />
368
+ </div>
369
+
370
+ <div class="graph-controls-toggle-row">
371
+ <span class="graph-controls-label">Show tags</span>
372
+ <Switch
373
+ aria-label="Show tags"
374
+ bind:checked={
375
+ () => settings.filters.showTags,
376
+ (checked) =>
377
+ onSettingsPatch({ filters: { showTags: checked } })
378
+ }
379
+ />
380
+ </div>
381
+
382
+ <div class="graph-controls-toggle-row">
383
+ <span class="graph-controls-label">Show attachments</span>
384
+ <Switch
385
+ aria-label="Show attachments"
386
+ bind:checked={
387
+ () => settings.filters.showAttachments,
388
+ (checked) =>
389
+ onSettingsPatch({ filters: { showAttachments: checked } })
390
+ }
391
+ />
392
+ </div>
393
+
394
+ <div class="graph-controls-toggle-row">
395
+ <span class="graph-controls-label">Existing files only</span>
396
+ <Switch
397
+ aria-label="Existing files only"
398
+ bind:checked={
399
+ () => settings.filters.existingFilesOnly,
400
+ (checked) =>
401
+ onSettingsPatch({
402
+ filters: { existingFilesOnly: checked },
403
+ })
404
+ }
405
+ />
406
+ </div>
407
+
408
+ <div class="graph-controls-toggle-row">
409
+ <span class="graph-controls-label">Show orphans</span>
410
+ <Switch
411
+ aria-label="Show orphans"
412
+ bind:checked={
413
+ () => settings.filters.showOrphans,
414
+ (checked) =>
415
+ onSettingsPatch({ filters: { showOrphans: checked } })
416
+ }
417
+ />
418
+ </div>
419
+ </div>
420
+ </Accordion.Content>
421
+ </Accordion.Item>
422
+
423
+ <Accordion.Item value="groups">
424
+ <Accordion.Trigger
425
+ style="height: 40px; min-height: 40px;"
426
+ indicatorPosition="start"
427
+ indicatorVariant="disclosure"
428
+ class="graph-controls-trigger"
429
+ >
430
+ <div class="graph-controls-trigger__content">
431
+ <span>Groups</span>
432
+ <span class="graph-controls-section-count"
433
+ >{settings.groups.length}</span
434
+ >
435
+ </div>
436
+ </Accordion.Trigger>
437
+
438
+ <Accordion.Content class="graph-controls-section-content">
439
+ <div class="graph-controls-groups">
440
+ {#each settings.groups as group, index (group.id)}
441
+ <SortableArrayItem
442
+ id={group.id}
443
+ {index}
444
+ sortableGroup="graph-groups"
445
+ dragging={draggingGroupId === group.id}
446
+ compact
447
+ inset="normal"
448
+ dragLabel={`Reorder Group ${index + 1}`}
449
+ canMoveUp={index > 0}
450
+ canMoveDown={index < settings.groups.length - 1}
451
+ onDragStart={startGroupDrag}
452
+ onKeyboardMove={moveGroup}
453
+ onRemove={() => removeGroup(group.id)}
454
+ >
455
+ <div class="graph-controls-group">
456
+ <div class="graph-controls-group__row">
457
+ <SearchFilterInput
458
+ ariaLabel={`Group ${index + 1} query`}
459
+ placeholder="Enter query…"
460
+ value={group.query}
461
+ density="compact"
462
+ inputMode="filter-query"
463
+ showSearchIcon={false}
464
+ showClearButton={false}
465
+ error={groupDiagnostics[group.id] ?? null}
466
+ editorExtensions={queryExtensions}
467
+ {filterSyntax}
468
+ onValueChange={(value) =>
469
+ updateGroup(group.id, {
470
+ query: value,
471
+ })}
472
+ />
473
+ <ColorPicker
474
+ value={group.color}
475
+ ariaLabel={`Group ${index + 1}`}
476
+ presentation="popover"
477
+ presets={groupColors}
478
+ onChange={(color) => updateGroup(group.id, { color })}
479
+ />
480
+ </div>
481
+ </div>
482
+ </SortableArrayItem>
483
+ {/each}
484
+
485
+ <Button
486
+ variant="outline"
487
+ size="sm"
488
+ class="graph-controls-add-group"
489
+ onclick={addGroup}
490
+ >
491
+ New group
492
+ </Button>
493
+ </div>
494
+ </Accordion.Content>
495
+ </Accordion.Item>
496
+
497
+ <Accordion.Item value="display">
498
+ <Accordion.Trigger
499
+ style="height: 40px; min-height: 40px;"
500
+ indicatorPosition="start"
501
+ indicatorVariant="disclosure"
502
+ class="graph-controls-trigger"
503
+ >
504
+ <div class="graph-controls-trigger__content">
505
+ <span>Display</span>
506
+ </div>
507
+ </Accordion.Trigger>
508
+
509
+ <Accordion.Content class="graph-controls-section-content">
510
+ <div class="graph-controls-field-list">
511
+ <div class="graph-controls-toggle-row">
512
+ <span class="graph-controls-label">Time-lapse</span>
513
+ <Button
514
+ variant={isAnimating ? "destructive" : "outline"}
515
+ size="sm"
516
+ aria-label={isAnimating
517
+ ? "Stop graph animation"
518
+ : "Animate graph"}
519
+ onclick={onToggleAnimation}
520
+ >
521
+ {isAnimating ? "Stop" : "Animate"}
522
+ </Button>
523
+ </div>
524
+
525
+ <div class="graph-controls-toggle-row">
526
+ <span class="graph-controls-label">Show arrows</span>
527
+ <Switch
528
+ aria-label="Show arrows"
529
+ bind:checked={
530
+ () => settings.display.showArrows,
531
+ (checked) =>
532
+ onSettingsPatch({ display: { showArrows: checked } })
533
+ }
534
+ />
535
+ </div>
536
+
537
+ <div class="graph-controls-slider">
538
+ <div class="graph-controls-slider__header">
539
+ <span>Label zoom threshold</span>
540
+ <span class="graph-controls-slider__value"
541
+ >{formatValue(settings.display.textFadeThreshold)}</span
542
+ >
543
+ </div>
544
+ <Slider
545
+ aria-label="Label zoom threshold"
546
+ type="single"
547
+ min={0.4}
548
+ max={1.6}
549
+ step={0.05}
550
+ class="graph-controls-slider__control"
551
+ bind:value={
552
+ () => settings.display.textFadeThreshold,
553
+ (value) =>
554
+ updateSlider(value, (next) => ({
555
+ display: { textFadeThreshold: next },
556
+ }))
557
+ }
558
+ />
559
+ </div>
560
+
561
+ <div class="graph-controls-slider">
562
+ <div class="graph-controls-slider__header">
563
+ <span>Hover activation delay</span>
564
+ <span class="graph-controls-slider__value"
565
+ >{formatValue(settings.display.hoverActivationDelayMs)} ms</span
566
+ >
567
+ </div>
568
+ <Slider
569
+ aria-label="Hover activation delay"
570
+ type="single"
571
+ min={0}
572
+ max={2000}
573
+ step={50}
574
+ class="graph-controls-slider__control"
575
+ bind:value={
576
+ () => settings.display.hoverActivationDelayMs,
577
+ (value) =>
578
+ updateSlider(value, (next) => ({
579
+ display: { hoverActivationDelayMs: next },
580
+ }))
581
+ }
582
+ />
583
+ </div>
584
+
585
+ <div class="graph-controls-slider">
586
+ <div class="graph-controls-slider__header">
587
+ <span>Hover release delay</span>
588
+ <span class="graph-controls-slider__value"
589
+ >{formatValue(settings.display.hoverReleaseDelayMs)} ms</span
590
+ >
591
+ </div>
592
+ <Slider
593
+ aria-label="Hover release delay"
594
+ type="single"
595
+ min={0}
596
+ max={2000}
597
+ step={50}
598
+ class="graph-controls-slider__control"
599
+ bind:value={
600
+ () => settings.display.hoverReleaseDelayMs,
601
+ (value) =>
602
+ updateSlider(value, (next) => ({
603
+ display: { hoverReleaseDelayMs: next },
604
+ }))
605
+ }
606
+ />
607
+ </div>
608
+
609
+ <div class="graph-controls-slider">
610
+ <div class="graph-controls-slider__header">
611
+ <span>Node size</span>
612
+ <span class="graph-controls-slider__value"
613
+ >{formatValue(settings.display.nodeSize)}</span
614
+ >
615
+ </div>
616
+ <Slider
617
+ aria-label="Node size"
618
+ type="single"
619
+ min={4}
620
+ max={18}
621
+ step={1}
622
+ class="graph-controls-slider__control"
623
+ bind:value={
624
+ () => settings.display.nodeSize,
625
+ (value) =>
626
+ updateSlider(value, (next) => ({
627
+ display: { nodeSize: next },
628
+ }))
629
+ }
630
+ />
631
+ </div>
632
+
633
+ <div class="graph-controls-slider">
634
+ <div class="graph-controls-slider__header">
635
+ <span>Link thickness</span>
636
+ <span class="graph-controls-slider__value"
637
+ >{formatValue(settings.display.linkThickness)}</span
638
+ >
639
+ </div>
640
+ <Slider
641
+ aria-label="Link thickness"
642
+ type="single"
643
+ min={0.1}
644
+ max={5}
645
+ step={0.1}
646
+ class="graph-controls-slider__control"
647
+ bind:value={
648
+ () => settings.display.linkThickness,
649
+ (value) =>
650
+ updateSlider(value, (next) => ({
651
+ display: { linkThickness: next },
652
+ }))
653
+ }
654
+ />
655
+ </div>
656
+
657
+ <div class="graph-controls-slider">
658
+ <div class="graph-controls-slider__header">
659
+ <span>Scroll zoom sensitivity</span>
660
+ <span class="graph-controls-slider__value"
661
+ >{formatValue(settings.display.wheelZoomSensitivity)}</span
662
+ >
663
+ </div>
664
+ <Slider
665
+ aria-label="Scroll zoom sensitivity"
666
+ type="single"
667
+ min={0.4}
668
+ max={1.6}
669
+ step={0.05}
670
+ class="graph-controls-slider__control"
671
+ bind:value={
672
+ () => settings.display.wheelZoomSensitivity,
673
+ (value) =>
674
+ updateSlider(value, (next) => ({
675
+ display: { wheelZoomSensitivity: next },
676
+ }))
677
+ }
678
+ />
679
+ </div>
680
+ </div>
681
+ </Accordion.Content>
682
+ </Accordion.Item>
683
+
684
+ <Accordion.Item value="forces">
685
+ <Accordion.Trigger
686
+ style="height: 40px; min-height: 40px;"
687
+ indicatorPosition="start"
688
+ indicatorVariant="disclosure"
689
+ class="graph-controls-trigger"
690
+ >
691
+ <div class="graph-controls-trigger__content">
692
+ <span>Forces</span>
693
+ </div>
694
+ </Accordion.Trigger>
695
+
696
+ <Accordion.Content class="graph-controls-section-content">
697
+ <div class="graph-controls-field-list">
698
+ <div class="graph-controls-slider">
699
+ <div class="graph-controls-slider__header">
700
+ <span>Center force</span>
701
+ <span class="graph-controls-slider__value"
702
+ >{formatValue(settings.forces.centerForce)}</span
703
+ >
704
+ </div>
705
+ <Slider
706
+ aria-label="Center force"
707
+ type="single"
708
+ min={0}
709
+ max={1}
710
+ step={0.01}
711
+ class="graph-controls-slider__control"
712
+ bind:value={
713
+ () => settings.forces.centerForce,
714
+ (value) =>
715
+ updateSlider(value, (next) => ({
716
+ forces: { centerForce: next },
717
+ }))
718
+ }
719
+ />
720
+ </div>
721
+
722
+ <div class="graph-controls-slider">
723
+ <div class="graph-controls-slider__header">
724
+ <span>Repel force</span>
725
+ <span class="graph-controls-slider__value"
726
+ >{formatValue(settings.forces.repelForce)}</span
727
+ >
728
+ </div>
729
+ <Slider
730
+ aria-label="Repel force"
731
+ type="single"
732
+ min={0}
733
+ max={20}
734
+ step={0.1}
735
+ class="graph-controls-slider__control"
736
+ bind:value={
737
+ () => settings.forces.repelForce,
738
+ (value) =>
739
+ updateSlider(value, (next) => ({
740
+ forces: { repelForce: next },
741
+ }))
742
+ }
743
+ />
744
+ </div>
745
+
746
+ <div class="graph-controls-slider">
747
+ <div class="graph-controls-slider__header">
748
+ <span>Link force</span>
749
+ <span class="graph-controls-slider__value"
750
+ >{formatValue(settings.forces.linkForce)}</span
751
+ >
752
+ </div>
753
+ <Slider
754
+ aria-label="Link force"
755
+ type="single"
756
+ min={0}
757
+ max={1}
758
+ step={0.01}
759
+ class="graph-controls-slider__control"
760
+ bind:value={
761
+ () => settings.forces.linkForce,
762
+ (value) =>
763
+ updateSlider(value, (next) => ({
764
+ forces: { linkForce: next },
765
+ }))
766
+ }
767
+ />
768
+ </div>
769
+
770
+ <div class="graph-controls-slider">
771
+ <div class="graph-controls-slider__header">
772
+ <span>Link distance</span>
773
+ <span class="graph-controls-slider__value"
774
+ >{formatValue(settings.forces.linkDistance)}</span
775
+ >
776
+ </div>
777
+ <Slider
778
+ aria-label="Link distance"
779
+ type="single"
780
+ min={30}
781
+ max={500}
782
+ step={1}
783
+ class="graph-controls-slider__control"
784
+ bind:value={
785
+ () => settings.forces.linkDistance,
786
+ (value) =>
787
+ updateSlider(value, (next) => ({
788
+ forces: { linkDistance: next },
789
+ }))
790
+ }
791
+ />
792
+ </div>
793
+ </div>
794
+ </Accordion.Content>
795
+ </Accordion.Item>
796
+
797
+ {#if isLocal}
798
+ <Accordion.Item value="local-graph">
799
+ <Accordion.Trigger
800
+ style="height: 40px; min-height: 40px;"
801
+ indicatorPosition="start"
802
+ indicatorVariant="disclosure"
803
+ class="graph-controls-trigger"
804
+ >
805
+ <div class="graph-controls-trigger__content">
806
+ <span>Local graph</span>
807
+ </div>
808
+ </Accordion.Trigger>
809
+
810
+ <Accordion.Content class="graph-controls-section-content">
811
+ <div class="graph-controls-slider">
812
+ <div class="graph-controls-slider__header">
813
+ <span>Depth</span>
814
+ <span class="graph-controls-slider__value"
815
+ >{formatValue(settings.localGraph.depth)}</span
816
+ >
817
+ </div>
818
+ <Slider
819
+ aria-label="Depth"
820
+ type="single"
821
+ min={1}
822
+ max={6}
823
+ step={1}
824
+ class="graph-controls-slider__control"
825
+ bind:value={
826
+ () => settings.localGraph.depth,
827
+ (value) =>
828
+ updateSlider(value, (next) => ({
829
+ localGraph: { depth: next },
830
+ }))
831
+ }
832
+ />
833
+ </div>
834
+ </Accordion.Content>
835
+ </Accordion.Item>
836
+ {/if}
837
+ </Accordion.Root>
838
+ </div>
839
+ {/if}
840
+ </div>