@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/client.js CHANGED
@@ -2,9 +2,34 @@
2
2
  import { hydrateRoot } from "hono/jsx/dom/client";
3
3
 
4
4
  // components/garden-explorer.tsx
5
- import { layoutRadialGraph } from "@riebeckite/core/client";
5
+ import {
6
+ layoutForceGraph,
7
+ layoutRadialGraph,
8
+ shouldGuardForceLayout
9
+ } from "@riebeckite/core/client";
6
10
  import { useEffect, useMemo, useState } from "hono/jsx";
7
11
 
12
+ // src/garden-explorer.ts
13
+ function getGardenExplorerLocalGraphNotes(notes, selectedSlug, depth) {
14
+ const noteBySlug = new Map(notes.map((note) => [note.slug, note]));
15
+ const visible = /* @__PURE__ */ new Set([selectedSlug]);
16
+ let frontier = [selectedSlug];
17
+ for (let level = 0; level < depth; level++) {
18
+ const nextFrontier = [];
19
+ for (const slug of frontier) {
20
+ const note = noteBySlug.get(slug);
21
+ if (!note) continue;
22
+ for (const neighbor of [...note.outgoing, ...note.backlinks]) {
23
+ if (!noteBySlug.has(neighbor) || visible.has(neighbor)) continue;
24
+ visible.add(neighbor);
25
+ nextFrontier.push(neighbor);
26
+ }
27
+ }
28
+ frontier = nextFrontier;
29
+ }
30
+ return notes.filter((note) => visible.has(note.slug));
31
+ }
32
+
8
33
  // src/search-notes.ts
9
34
  var FIELD_WEIGHTS = {
10
35
  slug: 64,
@@ -158,11 +183,12 @@ function LinkedNoteSection(props) {
158
183
  }
159
184
 
160
185
  // components/garden-explorer.tsx
161
- import { jsx as jsx2, jsxs as jsxs2 } from "hono/jsx/jsx-runtime";
186
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "hono/jsx/jsx-runtime";
162
187
  var GRAPH_WIDTH = 900;
163
188
  var GRAPH_HEIGHT = 620;
164
189
  var MIN_SCALE = 0.6;
165
190
  var MAX_SCALE = 2.4;
191
+ var DRAG_THRESHOLD_PX = 4;
166
192
  function GardenExplorer(props) {
167
193
  const noteBySlug = useMemo(
168
194
  () => new Map(props.data.notes.map((note) => [note.slug, note])),
@@ -175,7 +201,13 @@ function GardenExplorer(props) {
175
201
  () => props.data.notes[0]?.slug ?? ""
176
202
  );
177
203
  const [mobilePanel, setMobilePanel] = useState("graph");
204
+ const [graphMode, setGraphMode] = useState("local");
205
+ const [hoveredSlug, setHoveredSlug] = useState(null);
178
206
  const [view, setView] = useState({ x: 0, y: 0, scale: 1 });
207
+ const [drag, setDrag] = useState(null);
208
+ const [suppressNodeNavigation, setSuppressNodeNavigation] = useState(false);
209
+ const [pinnedNodes, setPinnedNodes] = useState({});
210
+ const [forceLayoutApproved, setForceLayoutApproved] = useState(false);
179
211
  useEffect(() => {
180
212
  const params = new URLSearchParams(window.location.search);
181
213
  const note = params.get("note");
@@ -200,8 +232,9 @@ function GardenExplorer(props) {
200
232
  return true;
201
233
  });
202
234
  const searched = query ? searchGardenExplorerNotes(base, query) : base;
203
- return searched.slice(0, 80);
235
+ return searched;
204
236
  }, [props.data.notes, query, selectedTag, selectedFolder]);
237
+ const listNotes = useMemo(() => filteredNotes.slice(0, 80), [filteredNotes]);
205
238
  const visibleSlugSet = useMemo(
206
239
  () => new Set(filteredNotes.map((note) => note.slug)),
207
240
  [filteredNotes]
@@ -212,29 +245,140 @@ function GardenExplorer(props) {
212
245
  ),
213
246
  [props.data.edges, visibleSlugSet]
214
247
  );
248
+ const graphNotes = useMemo(() => {
249
+ if (graphMode === "local") {
250
+ return getGardenExplorerLocalGraphNotes(
251
+ filteredNotes,
252
+ selectedSlug,
253
+ props.data.options.depth
254
+ );
255
+ }
256
+ const notes = filteredNotes;
257
+ const selectedNote2 = noteBySlug.get(selectedSlug);
258
+ if (selectedNote2 && !notes.some((n) => n.slug === selectedSlug)) {
259
+ return [selectedNote2, ...notes];
260
+ }
261
+ return notes;
262
+ }, [
263
+ filteredNotes,
264
+ graphMode,
265
+ selectedSlug,
266
+ props.data.options.depth,
267
+ noteBySlug
268
+ ]);
269
+ const graphSlugSet = useMemo(
270
+ () => new Set(graphNotes.map((note) => note.slug)),
271
+ [graphNotes]
272
+ );
273
+ const graphEdges = useMemo(
274
+ () => visibleEdges.filter(
275
+ (edge) => graphSlugSet.has(edge.source) && graphSlugSet.has(edge.target)
276
+ ),
277
+ [visibleEdges, graphSlugSet]
278
+ );
279
+ const graphKey = useMemo(
280
+ () => graphNotes.map((note) => note.slug).join("\0"),
281
+ [graphNotes]
282
+ );
283
+ const shouldGuard = shouldGuardForceLayout({
284
+ layout: props.data.options.layout,
285
+ mode: graphMode,
286
+ nodeCount: graphNotes.length,
287
+ approved: forceLayoutApproved
288
+ });
289
+ const baseGraphNodes = useMemo(() => {
290
+ if (shouldGuard) {
291
+ return layoutRadialGraph(graphNotes, {
292
+ width: GRAPH_WIDTH,
293
+ height: GRAPH_HEIGHT,
294
+ centerSlug: selectedSlug
295
+ });
296
+ }
297
+ return layoutNodes(graphNotes, selectedSlug, props.data.options);
298
+ }, [shouldGuard, graphNotes, selectedSlug, props.data.options]);
215
299
  const graphNodes = useMemo(
216
- () => layoutNodes(filteredNotes, selectedSlug),
217
- [filteredNotes, selectedSlug]
300
+ () => applyPinnedNodes(baseGraphNodes, pinnedNodes),
301
+ [baseGraphNodes, pinnedNodes]
218
302
  );
219
- const selectedNote = noteBySlug.get(selectedSlug) ?? filteredNotes[0] ?? null;
303
+ const selectedNote = noteBySlug.get(selectedSlug) ?? listNotes[0] ?? null;
220
304
  const relatedNotes = useMemo(
221
305
  () => selectedNote ? getRelatedNotes(selectedNote, props.data.notes) : [],
222
306
  [selectedNote, props.data.notes]
223
307
  );
308
+ const emphasizedSlugs = useMemo(
309
+ () => getRelatedSlugSet(selectedSlug, hoveredSlug, graphEdges),
310
+ [selectedSlug, hoveredSlug, graphEdges]
311
+ );
312
+ useEffect(() => {
313
+ if (filteredNotes.length === 0 || visibleSlugSet.has(selectedSlug)) return;
314
+ setSelectedSlug(listNotes[0]?.slug ?? "");
315
+ }, [filteredNotes, selectedSlug, visibleSlugSet, listNotes]);
316
+ useEffect(() => {
317
+ setHoveredSlug(null);
318
+ setDrag(null);
319
+ setSuppressNodeNavigation(false);
320
+ setPinnedNodes({});
321
+ setForceLayoutApproved(false);
322
+ }, [graphKey, graphMode, props.data.options.layout]);
224
323
  const selectNote = (slug) => {
225
324
  setSelectedSlug(slug);
226
325
  setMobilePanel("details");
227
326
  };
327
+ const navigateToNote = (slug) => {
328
+ const note = noteBySlug.get(slug);
329
+ if (!note) return;
330
+ window.location.href = note.permalink;
331
+ };
228
332
  const clearFilters = () => {
229
333
  setQuery("");
230
334
  setSelectedTag(null);
231
335
  setSelectedFolder(null);
232
336
  };
233
- const zoom = (delta) => {
337
+ const approveForceLayout = () => {
338
+ setForceLayoutApproved(true);
339
+ };
340
+ const zoom = (delta, origin) => {
341
+ setView((current) => zoomView(current, delta, origin));
342
+ };
343
+ const toSvgPoint = (event) => {
344
+ const svg = event.currentTarget;
345
+ const rect = svg.getBoundingClientRect();
346
+ return {
347
+ x: (event.clientX - rect.left) / rect.width * GRAPH_WIDTH,
348
+ y: (event.clientY - rect.top) / rect.height * GRAPH_HEIGHT
349
+ };
350
+ };
351
+ const toGraphPoint = (event, current) => {
352
+ const point = toSvgPoint(event);
353
+ return {
354
+ x: (point.x - current.x) / current.scale,
355
+ y: (point.y - current.y) / current.scale
356
+ };
357
+ };
358
+ const startPan = (event) => {
359
+ const point = toSvgPoint(event);
360
+ setDrag({ kind: "pan", x: point.x, y: point.y });
361
+ event.currentTarget.setPointerCapture(event.pointerId);
362
+ };
363
+ const movePan = (event, currentDrag) => {
364
+ const point = toSvgPoint(event);
234
365
  setView((current) => ({
235
366
  ...current,
236
- scale: clamp(current.scale + delta, MIN_SCALE, MAX_SCALE)
367
+ x: current.x + point.x - currentDrag.x,
368
+ y: current.y + point.y - currentDrag.y
237
369
  }));
370
+ setDrag({ kind: "pan", x: point.x, y: point.y });
371
+ };
372
+ const moveNode = (event, currentDrag) => {
373
+ const hasMoved = currentDrag.hasMoved || Math.hypot(
374
+ event.clientX - currentDrag.startClientX,
375
+ event.clientY - currentDrag.startClientY
376
+ ) >= DRAG_THRESHOLD_PX;
377
+ if (!hasMoved) return;
378
+ const point = toGraphPoint(event, view);
379
+ setSuppressNodeNavigation(true);
380
+ setPinnedNodes((current) => ({ ...current, [currentDrag.slug]: point }));
381
+ if (!currentDrag.hasMoved) setDrag({ ...currentDrag, hasMoved: true });
238
382
  };
239
383
  return /* @__PURE__ */ jsxs2("div", { class: "garden-explorer rr-garden-explorer", "data-garden-explorer": true, children: [
240
384
  /* @__PURE__ */ jsxs2("fieldset", { class: "garden-explorer__mobile-tabs", children: [
@@ -275,7 +419,7 @@ function GardenExplorer(props) {
275
419
  }
276
420
  )
277
421
  ] }),
278
- /* @__PURE__ */ jsx2(
422
+ props.data.options.showTags ? /* @__PURE__ */ jsx2(
279
423
  FilterList,
280
424
  {
281
425
  title: "Tags",
@@ -287,8 +431,8 @@ function GardenExplorer(props) {
287
431
  selectedKey: selectedTag,
288
432
  onSelect: (key) => setSelectedTag(key === selectedTag ? null : key)
289
433
  }
290
- ),
291
- /* @__PURE__ */ jsx2(
434
+ ) : null,
435
+ props.data.options.showFolders ? /* @__PURE__ */ jsx2(
292
436
  FilterList,
293
437
  {
294
438
  title: "Folders",
@@ -300,13 +444,21 @@ function GardenExplorer(props) {
300
444
  selectedKey: selectedFolder,
301
445
  onSelect: (key) => setSelectedFolder(key === selectedFolder ? null : key)
302
446
  }
303
- ),
447
+ ) : null,
304
448
  /* @__PURE__ */ jsxs2("div", { class: "garden-explorer__section", children: [
305
- /* @__PURE__ */ jsx2("p", { class: "garden-explorer__eyebrow", children: "Notes" }),
449
+ /* @__PURE__ */ jsxs2("p", { class: "garden-explorer__eyebrow", children: [
450
+ "Notes",
451
+ filteredNotes.length > listNotes.length && /* @__PURE__ */ jsxs2("span", { class: "garden-explorer__count-badge", children: [
452
+ "showing ",
453
+ listNotes.length,
454
+ " of ",
455
+ filteredNotes.length
456
+ ] })
457
+ ] }),
306
458
  /* @__PURE__ */ jsx2(
307
459
  NoteList,
308
460
  {
309
- notes: filteredNotes,
461
+ notes: listNotes,
310
462
  selectedSlug,
311
463
  onSelect: selectNote
312
464
  }
@@ -316,10 +468,53 @@ function GardenExplorer(props) {
316
468
  /* @__PURE__ */ jsxs2("section", { class: panelClass("graph", mobilePanel), "aria-label": "Note graph", children: [
317
469
  /* @__PURE__ */ jsxs2("div", { class: "garden-explorer__graph-toolbar", children: [
318
470
  /* @__PURE__ */ jsxs2("span", { children: [
319
- filteredNotes.length,
320
- " notes"
471
+ graphMode === "local" ? "Local" : "Global",
472
+ " graph ·",
473
+ " ",
474
+ graphNotes.length,
475
+ " notes",
476
+ graphMode === "global" && filteredNotes.length !== graphNotes.length && /* @__PURE__ */ jsxs2(Fragment, { children: [
477
+ " (of ",
478
+ filteredNotes.length,
479
+ " filtered)"
480
+ ] }),
481
+ shouldGuard && /* @__PURE__ */ jsxs2("span", { class: "garden-explorer__perf-warning", "aria-live": "polite", children: [
482
+ "⚠ Force layout may freeze this page (",
483
+ graphNotes.length,
484
+ " nodes)"
485
+ ] }),
486
+ shouldGuard && !forceLayoutApproved && /* @__PURE__ */ jsxs2(Fragment, { children: [
487
+ /* @__PURE__ */ jsx2("span", { class: "garden-explorer__perf-warning", children: "Force layout not run automatically." }),
488
+ /* @__PURE__ */ jsx2(
489
+ "button",
490
+ {
491
+ type: "button",
492
+ class: "garden-explorer__force-approve",
493
+ onClick: approveForceLayout,
494
+ children: "Run force layout anyway"
495
+ }
496
+ )
497
+ ] })
321
498
  ] }),
322
499
  /* @__PURE__ */ jsxs2("div", { children: [
500
+ /* @__PURE__ */ jsx2(
501
+ "button",
502
+ {
503
+ type: "button",
504
+ "aria-pressed": graphMode === "local",
505
+ onClick: () => setGraphMode("local"),
506
+ children: "Local"
507
+ }
508
+ ),
509
+ /* @__PURE__ */ jsx2(
510
+ "button",
511
+ {
512
+ type: "button",
513
+ "aria-pressed": graphMode === "global",
514
+ onClick: () => setGraphMode("global"),
515
+ children: "Global"
516
+ }
517
+ ),
323
518
  /* @__PURE__ */ jsx2(
324
519
  "button",
325
520
  {
@@ -357,25 +552,44 @@ function GardenExplorer(props) {
357
552
  "aria-label": "Interactive graph of notes and internal links",
358
553
  onWheel: (event) => {
359
554
  event.preventDefault();
360
- zoom(event.deltaY > 0 ? -0.1 : 0.1);
555
+ zoom(event.deltaY > 0 ? -0.1 : 0.1, toSvgPoint(event));
556
+ },
557
+ onPointerDown: (event) => {
558
+ const target = event.target;
559
+ if (target.closest(".garden-graph__node")) return;
560
+ startPan(event);
361
561
  },
362
562
  onPointerMove: (event) => {
363
- if (event.buttons !== 1) return;
364
- setView((current) => ({
365
- ...current,
366
- x: current.x + event.movementX / current.scale,
367
- y: current.y + event.movementY / current.scale
368
- }));
563
+ if (!drag) return;
564
+ if (drag.kind === "pan") {
565
+ movePan(event, drag);
566
+ return;
567
+ }
568
+ moveNode(event, drag);
569
+ },
570
+ onPointerUp: (event) => {
571
+ setDrag(null);
572
+ const svg = event.currentTarget;
573
+ if (svg.hasPointerCapture(event.pointerId)) {
574
+ svg.releasePointerCapture(event.pointerId);
575
+ }
576
+ },
577
+ onPointerCancel: () => {
578
+ setDrag(null);
579
+ setSuppressNodeNavigation(false);
369
580
  },
370
581
  children: /* @__PURE__ */ jsxs2("g", { transform: `translate(${view.x} ${view.y}) scale(${view.scale})`, children: [
371
- visibleEdges.map((edge) => {
582
+ graphEdges.map((edge) => {
372
583
  const source = graphNodes.get(edge.source);
373
584
  const target = graphNodes.get(edge.target);
374
585
  if (!source || !target) return null;
586
+ const emphasized = emphasizedSlugs.has(edge.source) && emphasizedSlugs.has(edge.target);
375
587
  return /* @__PURE__ */ jsx2(
376
588
  "line",
377
589
  {
378
590
  class: "garden-graph__edge",
591
+ "data-muted": emphasizedSlugs.size > 0 && !emphasized ? "true" : "false",
592
+ "data-emphasized": emphasized ? "true" : "false",
379
593
  x1: source.x,
380
594
  y1: source.y,
381
595
  x2: target.x,
@@ -384,33 +598,62 @@ function GardenExplorer(props) {
384
598
  `${edge.source}-${edge.target}`
385
599
  );
386
600
  }),
387
- filteredNotes.map((note) => {
601
+ graphNotes.map((note) => {
388
602
  const node = graphNodes.get(note.slug);
389
603
  if (!node) return null;
390
604
  const selected = note.slug === selectedSlug;
391
- return /* @__PURE__ */ jsx2("g", { class: "garden-graph__node", children: /* @__PURE__ */ jsxs2(
392
- "a",
605
+ const active = emphasizedSlugs.size === 0 || emphasizedSlugs.has(note.slug);
606
+ return /* @__PURE__ */ jsx2(
607
+ "g",
393
608
  {
394
- href: `/explore?note=${encodeURIComponent(note.slug)}`,
395
- "aria-label": `Select ${note.title}`,
396
- onClick: (event) => {
397
- event.preventDefault();
398
- selectNote(note.slug);
399
- },
400
- children: [
401
- /* @__PURE__ */ jsx2(
402
- "circle",
403
- {
404
- cx: node.x,
405
- cy: node.y,
406
- r: selected ? 15 : node.radius,
407
- "data-selected": selected ? "true" : "false"
408
- }
409
- ),
410
- /* @__PURE__ */ jsx2("text", { x: node.x + 18, y: node.y + 5, children: note.title })
411
- ]
412
- }
413
- ) }, note.slug);
609
+ class: "garden-graph__node",
610
+ "data-muted": active ? "false" : "true",
611
+ onPointerEnter: () => setHoveredSlug(note.slug),
612
+ onPointerLeave: () => setHoveredSlug(null),
613
+ children: /* @__PURE__ */ jsxs2(
614
+ "a",
615
+ {
616
+ href: note.permalink,
617
+ "aria-label": `Open ${note.title}`,
618
+ onClick: (event) => {
619
+ event.preventDefault();
620
+ if (suppressNodeNavigation) {
621
+ setSuppressNodeNavigation(false);
622
+ return;
623
+ }
624
+ navigateToNote(note.slug);
625
+ },
626
+ children: [
627
+ /* @__PURE__ */ jsx2(
628
+ "circle",
629
+ {
630
+ cx: node.x,
631
+ cy: node.y,
632
+ r: selected ? 15 : node.radius * props.data.options.nodeSize,
633
+ "data-selected": selected ? "true" : "false",
634
+ onPointerDown: (event) => {
635
+ event.preventDefault();
636
+ event.stopPropagation();
637
+ setSelectedSlug(note.slug);
638
+ setSuppressNodeNavigation(false);
639
+ setDrag({
640
+ kind: "node",
641
+ slug: note.slug,
642
+ startClientX: event.clientX,
643
+ startClientY: event.clientY,
644
+ hasMoved: false
645
+ });
646
+ event.currentTarget.setPointerCapture(event.pointerId);
647
+ }
648
+ }
649
+ ),
650
+ props.data.options.showLabels ? /* @__PURE__ */ jsx2("text", { x: node.x + 18, y: node.y + 5, children: note.title }) : null
651
+ ]
652
+ }
653
+ )
654
+ },
655
+ note.slug
656
+ );
414
657
  })
415
658
  ] })
416
659
  }
@@ -427,13 +670,30 @@ function GardenExplorer(props) {
427
670
  ) : /* @__PURE__ */ jsx2("p", { class: "garden-explorer__empty", children: "Select a note to inspect links." }) })
428
671
  ] });
429
672
  }
430
- function layoutNodes(notes, selectedSlug) {
431
- return layoutRadialGraph(notes, {
673
+ function layoutNodes(notes, selectedSlug, options) {
674
+ if (options.layout === "radial") {
675
+ return layoutRadialGraph(notes, {
676
+ width: GRAPH_WIDTH,
677
+ height: GRAPH_HEIGHT,
678
+ centerSlug: selectedSlug
679
+ });
680
+ }
681
+ return layoutForceGraph(notes, {
432
682
  width: GRAPH_WIDTH,
433
683
  height: GRAPH_HEIGHT,
434
- centerSlug: selectedSlug
684
+ centerSlug: selectedSlug,
685
+ linkDistance: options.linkDistance,
686
+ repulsion: options.repulsion
435
687
  });
436
688
  }
689
+ function applyPinnedNodes(layout, pinnedNodes) {
690
+ const next = new Map(layout);
691
+ for (const [slug, point] of Object.entries(pinnedNodes)) {
692
+ const current = next.get(slug);
693
+ if (current) next.set(slug, { ...current, x: point.x, y: point.y });
694
+ }
695
+ return next;
696
+ }
437
697
  function getRelatedNotes(selectedNote, notes) {
438
698
  const directSlugs = /* @__PURE__ */ new Set([
439
699
  ...selectedNote.outgoing,
@@ -447,12 +707,33 @@ function getRelatedNotes(selectedNote, notes) {
447
707
  (a, b) => b.score - a.score || a.note.title.localeCompare(b.note.title, "ja")
448
708
  ).slice(0, 8).map((item) => item.note);
449
709
  }
710
+ function getRelatedSlugSet(selectedSlug, hoveredSlug, edges) {
711
+ const origin = hoveredSlug ?? selectedSlug;
712
+ if (!origin) return /* @__PURE__ */ new Set();
713
+ const related = /* @__PURE__ */ new Set([origin]);
714
+ for (const edge of edges) {
715
+ if (edge.source === origin) related.add(edge.target);
716
+ if (edge.target === origin) related.add(edge.source);
717
+ }
718
+ return related;
719
+ }
450
720
  function panelClass(panel, activePanel) {
451
721
  return `garden-explorer__panel garden-explorer__panel--${panel}${panel === activePanel ? " garden-explorer__panel--active" : ""}`;
452
722
  }
453
723
  function clamp(value, min, max) {
454
724
  return Math.min(Math.max(value, min), max);
455
725
  }
726
+ function zoomView(current, delta, origin = { x: GRAPH_WIDTH / 2, y: GRAPH_HEIGHT / 2 }) {
727
+ const scale = clamp(current.scale + delta, MIN_SCALE, MAX_SCALE);
728
+ if (scale === current.scale) return current;
729
+ const graphX = (origin.x - current.x) / current.scale;
730
+ const graphY = (origin.y - current.y) / current.scale;
731
+ return {
732
+ x: origin.x - graphX * scale,
733
+ y: origin.y - graphY * scale,
734
+ scale
735
+ };
736
+ }
456
737
 
457
738
  // client.tsx
458
739
  import { jsx as jsx3 } from "hono/jsx/jsx-runtime";