@riebeckite/plugin-garden-explorer 0.0.14 → 0.0.16

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