@riebeckite/plugin-garden-explorer 0.0.13 → 0.0.15

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.js CHANGED
@@ -3,13 +3,12 @@ import { createClientEntry, definePlugin } from "@riebeckite/core";
3
3
 
4
4
  // src/garden-explorer.server.ts
5
5
  import {
6
- buildGraphEdges,
7
- isPublished
6
+ buildGraphEdges
8
7
  } from "@riebeckite/core";
9
8
  var MAX_BODY_LENGTH = 4e3;
10
9
  function getGardenExplorerData(args) {
11
10
  const graph = args.manifest.graph;
12
- const publishedEntries = graph.nodes().filter((entry) => isPublished(args.config, entry.frontmatter));
11
+ const publishedEntries = args.manifest.discoverableEntries;
13
12
  const publishedSlugs = new Set(publishedEntries.map((entry) => entry.slug));
14
13
  const notes = publishedEntries.map(
15
14
  (entry) => toGardenNote(entry, graph, publishedSlugs, args.resolveTitle)
@@ -18,9 +17,25 @@ function getGardenExplorerData(args) {
18
17
  notes,
19
18
  edges: buildGraphEdges(notes, publishedSlugs),
20
19
  tags: buildTags(notes),
21
- folders: buildFolders(notes)
20
+ folders: buildFolders(notes),
21
+ options: resolveGardenExplorerOptions(args.options)
22
22
  };
23
23
  }
24
+ function resolveGardenExplorerOptions(options = {}) {
25
+ return {
26
+ layout: resolveGraphLayout(options.layout),
27
+ depth: clampInteger(options.depth ?? 1, 0, 4),
28
+ showTags: options.showTags ?? true,
29
+ showFolders: options.showFolders ?? true,
30
+ nodeSize: clampNumber(options.nodeSize ?? 1, 0.6, 2),
31
+ linkDistance: clampNumber(options.linkDistance ?? 84, 36, 180),
32
+ repulsion: clampNumber(options.repulsion ?? 1800, 200, 8e3),
33
+ showLabels: options.showLabels ?? true
34
+ };
35
+ }
36
+ function resolveGraphLayout(layout) {
37
+ return layout === "radial" || layout === "force" ? layout : "force";
38
+ }
24
39
  function toGardenNote(entry, graph, publishedSlugs, resolveTitle) {
25
40
  const { outgoingSlugs, incomingSlugs } = graph.filterNeighbors(
26
41
  entry.slug,
@@ -87,15 +102,47 @@ function getEntryDate(frontmatter) {
87
102
  function toPlainText(html) {
88
103
  return html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/\s+/g, " ").trim();
89
104
  }
105
+ function clampInteger(value, min, max) {
106
+ return Math.round(clampNumber(value, min, max));
107
+ }
108
+ function clampNumber(value, min, max) {
109
+ if (!Number.isFinite(value)) return min;
110
+ return Math.min(Math.max(value, min), max);
111
+ }
90
112
 
91
113
  // src/garden-explorer-page.tsx
92
114
  import { escapeScriptJson } from "@riebeckite/core";
93
115
  import { renderToString } from "hono/jsx/dom/server";
94
116
 
95
117
  // components/garden-explorer.tsx
96
- import { layoutRadialGraph } from "@riebeckite/core/client";
118
+ import {
119
+ layoutForceGraph,
120
+ layoutRadialGraph,
121
+ shouldGuardForceLayout
122
+ } from "@riebeckite/core/client";
97
123
  import { useEffect, useMemo, useState } from "hono/jsx";
98
124
 
125
+ // src/garden-explorer.ts
126
+ function getGardenExplorerLocalGraphNotes(notes, selectedSlug, depth) {
127
+ const noteBySlug = new Map(notes.map((note) => [note.slug, note]));
128
+ const visible = /* @__PURE__ */ new Set([selectedSlug]);
129
+ let frontier = [selectedSlug];
130
+ for (let level = 0; level < depth; level++) {
131
+ const nextFrontier = [];
132
+ for (const slug of frontier) {
133
+ const note = noteBySlug.get(slug);
134
+ if (!note) continue;
135
+ for (const neighbor of [...note.outgoing, ...note.backlinks]) {
136
+ if (!noteBySlug.has(neighbor) || visible.has(neighbor)) continue;
137
+ visible.add(neighbor);
138
+ nextFrontier.push(neighbor);
139
+ }
140
+ }
141
+ frontier = nextFrontier;
142
+ }
143
+ return notes.filter((note) => visible.has(note.slug));
144
+ }
145
+
99
146
  // src/search-notes.ts
100
147
  var FIELD_WEIGHTS = {
101
148
  slug: 64,
@@ -249,11 +296,12 @@ function LinkedNoteSection(props) {
249
296
  }
250
297
 
251
298
  // components/garden-explorer.tsx
252
- import { jsx as jsx2, jsxs as jsxs2 } from "hono/jsx/jsx-runtime";
299
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "hono/jsx/jsx-runtime";
253
300
  var GRAPH_WIDTH = 900;
254
301
  var GRAPH_HEIGHT = 620;
255
302
  var MIN_SCALE = 0.6;
256
303
  var MAX_SCALE = 2.4;
304
+ var DRAG_THRESHOLD_PX = 4;
257
305
  function GardenExplorer(props) {
258
306
  const noteBySlug = useMemo(
259
307
  () => new Map(props.data.notes.map((note) => [note.slug, note])),
@@ -266,7 +314,13 @@ function GardenExplorer(props) {
266
314
  () => props.data.notes[0]?.slug ?? ""
267
315
  );
268
316
  const [mobilePanel, setMobilePanel] = useState("graph");
317
+ const [graphMode, setGraphMode] = useState("local");
318
+ const [hoveredSlug, setHoveredSlug] = useState(null);
269
319
  const [view, setView] = useState({ x: 0, y: 0, scale: 1 });
320
+ const [drag, setDrag] = useState(null);
321
+ const [suppressNodeNavigation, setSuppressNodeNavigation] = useState(false);
322
+ const [pinnedNodes, setPinnedNodes] = useState({});
323
+ const [forceLayoutApproved, setForceLayoutApproved] = useState(false);
270
324
  useEffect(() => {
271
325
  const params = new URLSearchParams(window.location.search);
272
326
  const note = params.get("note");
@@ -291,8 +345,9 @@ function GardenExplorer(props) {
291
345
  return true;
292
346
  });
293
347
  const searched = query ? searchGardenExplorerNotes(base, query) : base;
294
- return searched.slice(0, 80);
348
+ return searched;
295
349
  }, [props.data.notes, query, selectedTag, selectedFolder]);
350
+ const listNotes = useMemo(() => filteredNotes.slice(0, 80), [filteredNotes]);
296
351
  const visibleSlugSet = useMemo(
297
352
  () => new Set(filteredNotes.map((note) => note.slug)),
298
353
  [filteredNotes]
@@ -303,29 +358,140 @@ function GardenExplorer(props) {
303
358
  ),
304
359
  [props.data.edges, visibleSlugSet]
305
360
  );
361
+ const graphNotes = useMemo(() => {
362
+ if (graphMode === "local") {
363
+ return getGardenExplorerLocalGraphNotes(
364
+ filteredNotes,
365
+ selectedSlug,
366
+ props.data.options.depth
367
+ );
368
+ }
369
+ const notes = filteredNotes;
370
+ const selectedNote2 = noteBySlug.get(selectedSlug);
371
+ if (selectedNote2 && !notes.some((n) => n.slug === selectedSlug)) {
372
+ return [selectedNote2, ...notes];
373
+ }
374
+ return notes;
375
+ }, [
376
+ filteredNotes,
377
+ graphMode,
378
+ selectedSlug,
379
+ props.data.options.depth,
380
+ noteBySlug
381
+ ]);
382
+ const graphSlugSet = useMemo(
383
+ () => new Set(graphNotes.map((note) => note.slug)),
384
+ [graphNotes]
385
+ );
386
+ const graphEdges = useMemo(
387
+ () => visibleEdges.filter(
388
+ (edge) => graphSlugSet.has(edge.source) && graphSlugSet.has(edge.target)
389
+ ),
390
+ [visibleEdges, graphSlugSet]
391
+ );
392
+ const graphKey = useMemo(
393
+ () => graphNotes.map((note) => note.slug).join("\0"),
394
+ [graphNotes]
395
+ );
396
+ const shouldGuard = shouldGuardForceLayout({
397
+ layout: props.data.options.layout,
398
+ mode: graphMode,
399
+ nodeCount: graphNotes.length,
400
+ approved: forceLayoutApproved
401
+ });
402
+ const baseGraphNodes = useMemo(() => {
403
+ if (shouldGuard) {
404
+ return layoutRadialGraph(graphNotes, {
405
+ width: GRAPH_WIDTH,
406
+ height: GRAPH_HEIGHT,
407
+ centerSlug: selectedSlug
408
+ });
409
+ }
410
+ return layoutNodes(graphNotes, selectedSlug, props.data.options);
411
+ }, [shouldGuard, graphNotes, selectedSlug, props.data.options]);
306
412
  const graphNodes = useMemo(
307
- () => layoutNodes(filteredNotes, selectedSlug),
308
- [filteredNotes, selectedSlug]
413
+ () => applyPinnedNodes(baseGraphNodes, pinnedNodes),
414
+ [baseGraphNodes, pinnedNodes]
309
415
  );
310
- const selectedNote = noteBySlug.get(selectedSlug) ?? filteredNotes[0] ?? null;
416
+ const selectedNote = noteBySlug.get(selectedSlug) ?? listNotes[0] ?? null;
311
417
  const relatedNotes = useMemo(
312
418
  () => selectedNote ? getRelatedNotes(selectedNote, props.data.notes) : [],
313
419
  [selectedNote, props.data.notes]
314
420
  );
421
+ const emphasizedSlugs = useMemo(
422
+ () => getRelatedSlugSet(selectedSlug, hoveredSlug, graphEdges),
423
+ [selectedSlug, hoveredSlug, graphEdges]
424
+ );
425
+ useEffect(() => {
426
+ if (filteredNotes.length === 0 || visibleSlugSet.has(selectedSlug)) return;
427
+ setSelectedSlug(listNotes[0]?.slug ?? "");
428
+ }, [filteredNotes, selectedSlug, visibleSlugSet, listNotes]);
429
+ useEffect(() => {
430
+ setHoveredSlug(null);
431
+ setDrag(null);
432
+ setSuppressNodeNavigation(false);
433
+ setPinnedNodes({});
434
+ setForceLayoutApproved(false);
435
+ }, [graphKey, graphMode, props.data.options.layout]);
315
436
  const selectNote = (slug) => {
316
437
  setSelectedSlug(slug);
317
438
  setMobilePanel("details");
318
439
  };
440
+ const navigateToNote = (slug) => {
441
+ const note = noteBySlug.get(slug);
442
+ if (!note) return;
443
+ window.location.href = note.permalink;
444
+ };
319
445
  const clearFilters = () => {
320
446
  setQuery("");
321
447
  setSelectedTag(null);
322
448
  setSelectedFolder(null);
323
449
  };
324
- const zoom = (delta) => {
450
+ const approveForceLayout = () => {
451
+ setForceLayoutApproved(true);
452
+ };
453
+ const zoom = (delta, origin) => {
454
+ setView((current) => zoomView(current, delta, origin));
455
+ };
456
+ const toSvgPoint = (event) => {
457
+ const svg = event.currentTarget;
458
+ const rect = svg.getBoundingClientRect();
459
+ return {
460
+ x: (event.clientX - rect.left) / rect.width * GRAPH_WIDTH,
461
+ y: (event.clientY - rect.top) / rect.height * GRAPH_HEIGHT
462
+ };
463
+ };
464
+ const toGraphPoint = (event, current) => {
465
+ const point = toSvgPoint(event);
466
+ return {
467
+ x: (point.x - current.x) / current.scale,
468
+ y: (point.y - current.y) / current.scale
469
+ };
470
+ };
471
+ const startPan = (event) => {
472
+ const point = toSvgPoint(event);
473
+ setDrag({ kind: "pan", x: point.x, y: point.y });
474
+ event.currentTarget.setPointerCapture(event.pointerId);
475
+ };
476
+ const movePan = (event, currentDrag) => {
477
+ const point = toSvgPoint(event);
325
478
  setView((current) => ({
326
479
  ...current,
327
- scale: clamp(current.scale + delta, MIN_SCALE, MAX_SCALE)
480
+ x: current.x + point.x - currentDrag.x,
481
+ y: current.y + point.y - currentDrag.y
328
482
  }));
483
+ setDrag({ kind: "pan", x: point.x, y: point.y });
484
+ };
485
+ const moveNode = (event, currentDrag) => {
486
+ const hasMoved = currentDrag.hasMoved || Math.hypot(
487
+ event.clientX - currentDrag.startClientX,
488
+ event.clientY - currentDrag.startClientY
489
+ ) >= DRAG_THRESHOLD_PX;
490
+ if (!hasMoved) return;
491
+ const point = toGraphPoint(event, view);
492
+ setSuppressNodeNavigation(true);
493
+ setPinnedNodes((current) => ({ ...current, [currentDrag.slug]: point }));
494
+ if (!currentDrag.hasMoved) setDrag({ ...currentDrag, hasMoved: true });
329
495
  };
330
496
  return /* @__PURE__ */ jsxs2("div", { class: "garden-explorer rr-garden-explorer", "data-garden-explorer": true, children: [
331
497
  /* @__PURE__ */ jsxs2("fieldset", { class: "garden-explorer__mobile-tabs", children: [
@@ -366,7 +532,7 @@ function GardenExplorer(props) {
366
532
  }
367
533
  )
368
534
  ] }),
369
- /* @__PURE__ */ jsx2(
535
+ props.data.options.showTags ? /* @__PURE__ */ jsx2(
370
536
  FilterList,
371
537
  {
372
538
  title: "Tags",
@@ -378,8 +544,8 @@ function GardenExplorer(props) {
378
544
  selectedKey: selectedTag,
379
545
  onSelect: (key) => setSelectedTag(key === selectedTag ? null : key)
380
546
  }
381
- ),
382
- /* @__PURE__ */ jsx2(
547
+ ) : null,
548
+ props.data.options.showFolders ? /* @__PURE__ */ jsx2(
383
549
  FilterList,
384
550
  {
385
551
  title: "Folders",
@@ -391,13 +557,21 @@ function GardenExplorer(props) {
391
557
  selectedKey: selectedFolder,
392
558
  onSelect: (key) => setSelectedFolder(key === selectedFolder ? null : key)
393
559
  }
394
- ),
560
+ ) : null,
395
561
  /* @__PURE__ */ jsxs2("div", { class: "garden-explorer__section", children: [
396
- /* @__PURE__ */ jsx2("p", { class: "garden-explorer__eyebrow", children: "Notes" }),
562
+ /* @__PURE__ */ jsxs2("p", { class: "garden-explorer__eyebrow", children: [
563
+ "Notes",
564
+ filteredNotes.length > listNotes.length && /* @__PURE__ */ jsxs2("span", { class: "garden-explorer__count-badge", children: [
565
+ "showing ",
566
+ listNotes.length,
567
+ " of ",
568
+ filteredNotes.length
569
+ ] })
570
+ ] }),
397
571
  /* @__PURE__ */ jsx2(
398
572
  NoteList,
399
573
  {
400
- notes: filteredNotes,
574
+ notes: listNotes,
401
575
  selectedSlug,
402
576
  onSelect: selectNote
403
577
  }
@@ -407,10 +581,53 @@ function GardenExplorer(props) {
407
581
  /* @__PURE__ */ jsxs2("section", { class: panelClass("graph", mobilePanel), "aria-label": "Note graph", children: [
408
582
  /* @__PURE__ */ jsxs2("div", { class: "garden-explorer__graph-toolbar", children: [
409
583
  /* @__PURE__ */ jsxs2("span", { children: [
410
- filteredNotes.length,
411
- " notes"
584
+ graphMode === "local" ? "Local" : "Global",
585
+ " graph ·",
586
+ " ",
587
+ graphNotes.length,
588
+ " notes",
589
+ graphMode === "global" && filteredNotes.length !== graphNotes.length && /* @__PURE__ */ jsxs2(Fragment, { children: [
590
+ " (of ",
591
+ filteredNotes.length,
592
+ " filtered)"
593
+ ] }),
594
+ shouldGuard && /* @__PURE__ */ jsxs2("span", { class: "garden-explorer__perf-warning", "aria-live": "polite", children: [
595
+ "⚠ Force layout may freeze this page (",
596
+ graphNotes.length,
597
+ " nodes)"
598
+ ] }),
599
+ shouldGuard && !forceLayoutApproved && /* @__PURE__ */ jsxs2(Fragment, { children: [
600
+ /* @__PURE__ */ jsx2("span", { class: "garden-explorer__perf-warning", children: "Force layout not run automatically." }),
601
+ /* @__PURE__ */ jsx2(
602
+ "button",
603
+ {
604
+ type: "button",
605
+ class: "garden-explorer__force-approve",
606
+ onClick: approveForceLayout,
607
+ children: "Run force layout anyway"
608
+ }
609
+ )
610
+ ] })
412
611
  ] }),
413
612
  /* @__PURE__ */ jsxs2("div", { children: [
613
+ /* @__PURE__ */ jsx2(
614
+ "button",
615
+ {
616
+ type: "button",
617
+ "aria-pressed": graphMode === "local",
618
+ onClick: () => setGraphMode("local"),
619
+ children: "Local"
620
+ }
621
+ ),
622
+ /* @__PURE__ */ jsx2(
623
+ "button",
624
+ {
625
+ type: "button",
626
+ "aria-pressed": graphMode === "global",
627
+ onClick: () => setGraphMode("global"),
628
+ children: "Global"
629
+ }
630
+ ),
414
631
  /* @__PURE__ */ jsx2(
415
632
  "button",
416
633
  {
@@ -448,25 +665,44 @@ function GardenExplorer(props) {
448
665
  "aria-label": "Interactive graph of notes and internal links",
449
666
  onWheel: (event) => {
450
667
  event.preventDefault();
451
- zoom(event.deltaY > 0 ? -0.1 : 0.1);
668
+ zoom(event.deltaY > 0 ? -0.1 : 0.1, toSvgPoint(event));
669
+ },
670
+ onPointerDown: (event) => {
671
+ const target = event.target;
672
+ if (target.closest(".garden-graph__node")) return;
673
+ startPan(event);
452
674
  },
453
675
  onPointerMove: (event) => {
454
- if (event.buttons !== 1) return;
455
- setView((current) => ({
456
- ...current,
457
- x: current.x + event.movementX / current.scale,
458
- y: current.y + event.movementY / current.scale
459
- }));
676
+ if (!drag) return;
677
+ if (drag.kind === "pan") {
678
+ movePan(event, drag);
679
+ return;
680
+ }
681
+ moveNode(event, drag);
682
+ },
683
+ onPointerUp: (event) => {
684
+ setDrag(null);
685
+ const svg = event.currentTarget;
686
+ if (svg.hasPointerCapture(event.pointerId)) {
687
+ svg.releasePointerCapture(event.pointerId);
688
+ }
689
+ },
690
+ onPointerCancel: () => {
691
+ setDrag(null);
692
+ setSuppressNodeNavigation(false);
460
693
  },
461
694
  children: /* @__PURE__ */ jsxs2("g", { transform: `translate(${view.x} ${view.y}) scale(${view.scale})`, children: [
462
- visibleEdges.map((edge) => {
695
+ graphEdges.map((edge) => {
463
696
  const source = graphNodes.get(edge.source);
464
697
  const target = graphNodes.get(edge.target);
465
698
  if (!source || !target) return null;
699
+ const emphasized = emphasizedSlugs.has(edge.source) && emphasizedSlugs.has(edge.target);
466
700
  return /* @__PURE__ */ jsx2(
467
701
  "line",
468
702
  {
469
703
  class: "garden-graph__edge",
704
+ "data-muted": emphasizedSlugs.size > 0 && !emphasized ? "true" : "false",
705
+ "data-emphasized": emphasized ? "true" : "false",
470
706
  x1: source.x,
471
707
  y1: source.y,
472
708
  x2: target.x,
@@ -475,33 +711,62 @@ function GardenExplorer(props) {
475
711
  `${edge.source}-${edge.target}`
476
712
  );
477
713
  }),
478
- filteredNotes.map((note) => {
714
+ graphNotes.map((note) => {
479
715
  const node = graphNodes.get(note.slug);
480
716
  if (!node) return null;
481
717
  const selected = note.slug === selectedSlug;
482
- return /* @__PURE__ */ jsx2("g", { class: "garden-graph__node", children: /* @__PURE__ */ jsxs2(
483
- "a",
718
+ const active = emphasizedSlugs.size === 0 || emphasizedSlugs.has(note.slug);
719
+ return /* @__PURE__ */ jsx2(
720
+ "g",
484
721
  {
485
- href: `/explore?note=${encodeURIComponent(note.slug)}`,
486
- "aria-label": `Select ${note.title}`,
487
- onClick: (event) => {
488
- event.preventDefault();
489
- selectNote(note.slug);
490
- },
491
- children: [
492
- /* @__PURE__ */ jsx2(
493
- "circle",
494
- {
495
- cx: node.x,
496
- cy: node.y,
497
- r: selected ? 15 : node.radius,
498
- "data-selected": selected ? "true" : "false"
499
- }
500
- ),
501
- /* @__PURE__ */ jsx2("text", { x: node.x + 18, y: node.y + 5, children: note.title })
502
- ]
503
- }
504
- ) }, note.slug);
722
+ class: "garden-graph__node",
723
+ "data-muted": active ? "false" : "true",
724
+ onPointerEnter: () => setHoveredSlug(note.slug),
725
+ onPointerLeave: () => setHoveredSlug(null),
726
+ children: /* @__PURE__ */ jsxs2(
727
+ "a",
728
+ {
729
+ href: note.permalink,
730
+ "aria-label": `Open ${note.title}`,
731
+ onClick: (event) => {
732
+ event.preventDefault();
733
+ if (suppressNodeNavigation) {
734
+ setSuppressNodeNavigation(false);
735
+ return;
736
+ }
737
+ navigateToNote(note.slug);
738
+ },
739
+ children: [
740
+ /* @__PURE__ */ jsx2(
741
+ "circle",
742
+ {
743
+ cx: node.x,
744
+ cy: node.y,
745
+ r: selected ? 15 : node.radius * props.data.options.nodeSize,
746
+ "data-selected": selected ? "true" : "false",
747
+ onPointerDown: (event) => {
748
+ event.preventDefault();
749
+ event.stopPropagation();
750
+ setSelectedSlug(note.slug);
751
+ setSuppressNodeNavigation(false);
752
+ setDrag({
753
+ kind: "node",
754
+ slug: note.slug,
755
+ startClientX: event.clientX,
756
+ startClientY: event.clientY,
757
+ hasMoved: false
758
+ });
759
+ event.currentTarget.setPointerCapture(event.pointerId);
760
+ }
761
+ }
762
+ ),
763
+ props.data.options.showLabels ? /* @__PURE__ */ jsx2("text", { x: node.x + 18, y: node.y + 5, children: note.title }) : null
764
+ ]
765
+ }
766
+ )
767
+ },
768
+ note.slug
769
+ );
505
770
  })
506
771
  ] })
507
772
  }
@@ -518,13 +783,30 @@ function GardenExplorer(props) {
518
783
  ) : /* @__PURE__ */ jsx2("p", { class: "garden-explorer__empty", children: "Select a note to inspect links." }) })
519
784
  ] });
520
785
  }
521
- function layoutNodes(notes, selectedSlug) {
522
- return layoutRadialGraph(notes, {
786
+ function layoutNodes(notes, selectedSlug, options) {
787
+ if (options.layout === "radial") {
788
+ return layoutRadialGraph(notes, {
789
+ width: GRAPH_WIDTH,
790
+ height: GRAPH_HEIGHT,
791
+ centerSlug: selectedSlug
792
+ });
793
+ }
794
+ return layoutForceGraph(notes, {
523
795
  width: GRAPH_WIDTH,
524
796
  height: GRAPH_HEIGHT,
525
- centerSlug: selectedSlug
797
+ centerSlug: selectedSlug,
798
+ linkDistance: options.linkDistance,
799
+ repulsion: options.repulsion
526
800
  });
527
801
  }
802
+ function applyPinnedNodes(layout, pinnedNodes) {
803
+ const next = new Map(layout);
804
+ for (const [slug, point] of Object.entries(pinnedNodes)) {
805
+ const current = next.get(slug);
806
+ if (current) next.set(slug, { ...current, x: point.x, y: point.y });
807
+ }
808
+ return next;
809
+ }
528
810
  function getRelatedNotes(selectedNote, notes) {
529
811
  const directSlugs = /* @__PURE__ */ new Set([
530
812
  ...selectedNote.outgoing,
@@ -538,12 +820,33 @@ function getRelatedNotes(selectedNote, notes) {
538
820
  (a, b) => b.score - a.score || a.note.title.localeCompare(b.note.title, "ja")
539
821
  ).slice(0, 8).map((item) => item.note);
540
822
  }
823
+ function getRelatedSlugSet(selectedSlug, hoveredSlug, edges) {
824
+ const origin = hoveredSlug ?? selectedSlug;
825
+ if (!origin) return /* @__PURE__ */ new Set();
826
+ const related = /* @__PURE__ */ new Set([origin]);
827
+ for (const edge of edges) {
828
+ if (edge.source === origin) related.add(edge.target);
829
+ if (edge.target === origin) related.add(edge.source);
830
+ }
831
+ return related;
832
+ }
541
833
  function panelClass(panel, activePanel) {
542
834
  return `garden-explorer__panel garden-explorer__panel--${panel}${panel === activePanel ? " garden-explorer__panel--active" : ""}`;
543
835
  }
544
836
  function clamp(value, min, max) {
545
837
  return Math.min(Math.max(value, min), max);
546
838
  }
839
+ function zoomView(current, delta, origin = { x: GRAPH_WIDTH / 2, y: GRAPH_HEIGHT / 2 }) {
840
+ const scale = clamp(current.scale + delta, MIN_SCALE, MAX_SCALE);
841
+ if (scale === current.scale) return current;
842
+ const graphX = (origin.x - current.x) / current.scale;
843
+ const graphY = (origin.y - current.y) / current.scale;
844
+ return {
845
+ x: origin.x - graphX * scale,
846
+ y: origin.y - graphY * scale,
847
+ scale
848
+ };
849
+ }
547
850
 
548
851
  // src/garden-explorer-page.tsx
549
852
  import { jsx as jsx3, jsxs as jsxs3 } from "hono/jsx/jsx-runtime";
@@ -553,7 +856,7 @@ function renderGardenExplorerPage(data, siteTitle) {
553
856
  /* @__PURE__ */ jsxs3("header", { class: "garden-explorer-page__header", children: [
554
857
  /* @__PURE__ */ jsx3("p", { class: "garden-explorer-page__eyebrow", children: siteTitle }),
555
858
  /* @__PURE__ */ jsx3("h1", { class: "garden-explorer-page__title", children: "Garden Explorer" }),
556
- /* @__PURE__ */ jsx3("p", { class: "garden-explorer-page__description", children: "Graph, Search, Tags, Folders, Backlinks, and Related Notes を横断して Digital Garden を探索できます。" })
859
+ /* @__PURE__ */ jsx3("p", { class: "garden-explorer-page__description", children: "Explore your digital garden across graph, search, tags, folders, backlinks, and related notes." })
557
860
  ] }),
558
861
  /* @__PURE__ */ jsx3("div", { "data-riebeckite-garden-explorer": true, children: /* @__PURE__ */ jsx3(GardenExplorer, { data }) }),
559
862
  /* @__PURE__ */ jsx3(
@@ -571,7 +874,7 @@ function renderGardenExplorerPage(data, siteTitle) {
571
874
  }
572
875
 
573
876
  // index.ts
574
- function gardenExplorerPlugin() {
877
+ function gardenExplorerPlugin(options = {}) {
575
878
  return definePlugin({
576
879
  name: "garden-explorer",
577
880
  assets: [
@@ -586,6 +889,7 @@ function gardenExplorerPlugin() {
586
889
  {
587
890
  id: "garden-explorer",
588
891
  paths: ["/explore"],
892
+ outputDependencies: [{ type: "global" }],
589
893
  resolve: ({ pathname, manifest, config }) => {
590
894
  if (pathname !== "/explore" || !config) return null;
591
895
  const siteTitle = config.site.title;
@@ -598,7 +902,8 @@ function gardenExplorerPlugin() {
598
902
  getGardenExplorerData({
599
903
  manifest,
600
904
  config,
601
- resolveTitle: (slug, title) => typeof title === "string" && title.trim() ? title : slug
905
+ resolveTitle: (slug, title) => typeof title === "string" && title.trim() ? title : slug,
906
+ options
602
907
  }),
603
908
  siteTitle
604
909
  )
@@ -15,6 +15,19 @@ export type GardenExplorerNote = GardenExplorerSearchFields & {
15
15
  backlinks: string[];
16
16
  };
17
17
  export type GardenExplorerEdge = GraphEdge;
18
+ export type GardenExplorerGraphLayout = "force" | "radial";
19
+ export type GardenExplorerGraphMode = "local" | "global";
20
+ export type GardenExplorerOptions = {
21
+ layout: GardenExplorerGraphLayout;
22
+ depth: number;
23
+ showTags: boolean;
24
+ showFolders: boolean;
25
+ nodeSize: number;
26
+ linkDistance: number;
27
+ repulsion: number;
28
+ showLabels: boolean;
29
+ };
30
+ export type GardenExplorerPluginOptions = Partial<GardenExplorerOptions>;
18
31
  export type GardenExplorerTag = {
19
32
  name: string;
20
33
  count: number;
@@ -28,5 +41,7 @@ export type GardenExplorerData = {
28
41
  edges: GardenExplorerEdge[];
29
42
  tags: GardenExplorerTag[];
30
43
  folders: GardenExplorerFolder[];
44
+ options: GardenExplorerOptions;
31
45
  };
46
+ export declare function getGardenExplorerLocalGraphNotes(notes: GardenExplorerNote[], selectedSlug: string, depth: number): GardenExplorerNote[];
32
47
  export {};
@@ -1,9 +1,11 @@
1
1
  import { type ContentManifest, type ResolvedRiebeckiteConfig } from "@riebeckite/core";
2
- import type { GardenExplorerData } from "./garden-explorer.js";
2
+ import type { GardenExplorerData, GardenExplorerOptions, GardenExplorerPluginOptions } from "./garden-explorer.js";
3
3
  type TitleResolver = (slug: string, title: unknown) => string;
4
4
  export declare function getGardenExplorerData(args: {
5
5
  manifest: ContentManifest;
6
6
  config: ResolvedRiebeckiteConfig;
7
7
  resolveTitle: TitleResolver;
8
+ options?: GardenExplorerPluginOptions;
8
9
  }): GardenExplorerData;
10
+ export declare function resolveGardenExplorerOptions(options?: GardenExplorerPluginOptions): GardenExplorerOptions;
9
11
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riebeckite/plugin-garden-explorer",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "hono": "^4.12.25",
31
- "@riebeckite/core": "0.0.13"
31
+ "@riebeckite/core": "0.0.15"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"
@@ -63,6 +63,7 @@
63
63
  "./style.css"
64
64
  ],
65
65
  "scripts": {
66
- "build": "node ../../../scripts/build_package.mjs"
66
+ "build": "node ../../../scripts/build_package.mjs",
67
+ "test": "node --import tsx --test \"test/*.test.ts\""
67
68
  }
68
69
  }