@jjlmoya/utils-tabletop 1.5.0 → 1.7.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 (46) hide show
  1. package/package.json +5 -3
  2. package/src/entries.ts +5 -1
  3. package/src/index.ts +2 -0
  4. package/src/tests/locale_completeness.test.ts +2 -1
  5. package/src/tests/tool_validation.test.ts +2 -1
  6. package/src/tool/investigation-board/bibliography.astro +16 -0
  7. package/src/tool/investigation-board/bibliography.ts +12 -0
  8. package/src/tool/investigation-board/boardContextMenu.ts +64 -0
  9. package/src/tool/investigation-board/client.ts +223 -0
  10. package/src/tool/investigation-board/component.astro +23 -0
  11. package/src/tool/investigation-board/components/BoardCanvas.astro +39 -0
  12. package/src/tool/investigation-board/components/CardDrawer.astro +73 -0
  13. package/src/tool/investigation-board/components/ConnectionModal.astro +49 -0
  14. package/src/tool/investigation-board/components/ContextMenu.astro +26 -0
  15. package/src/tool/investigation-board/components/ControlPanel.astro +57 -0
  16. package/src/tool/investigation-board/connectionModal.ts +60 -0
  17. package/src/tool/investigation-board/dom.ts +89 -0
  18. package/src/tool/investigation-board/drawer.ts +110 -0
  19. package/src/tool/investigation-board/entry.ts +59 -0
  20. package/src/tool/investigation-board/i18n/de.ts +209 -0
  21. package/src/tool/investigation-board/i18n/en.ts +209 -0
  22. package/src/tool/investigation-board/i18n/es.ts +209 -0
  23. package/src/tool/investigation-board/i18n/fr.ts +209 -0
  24. package/src/tool/investigation-board/i18n/id.ts +209 -0
  25. package/src/tool/investigation-board/i18n/it.ts +209 -0
  26. package/src/tool/investigation-board/i18n/ja.ts +209 -0
  27. package/src/tool/investigation-board/i18n/ko.ts +209 -0
  28. package/src/tool/investigation-board/i18n/nl.ts +209 -0
  29. package/src/tool/investigation-board/i18n/pl.ts +209 -0
  30. package/src/tool/investigation-board/i18n/pt.ts +209 -0
  31. package/src/tool/investigation-board/i18n/ru.ts +209 -0
  32. package/src/tool/investigation-board/i18n/sv.ts +209 -0
  33. package/src/tool/investigation-board/i18n/tr.ts +209 -0
  34. package/src/tool/investigation-board/i18n/zh.ts +209 -0
  35. package/src/tool/investigation-board/index.ts +11 -0
  36. package/src/tool/investigation-board/interactDrag.ts +58 -0
  37. package/src/tool/investigation-board/investigation-board.css +799 -0
  38. package/src/tool/investigation-board/layout.ts +16 -0
  39. package/src/tool/investigation-board/logic.test.ts +105 -0
  40. package/src/tool/investigation-board/logic.ts +106 -0
  41. package/src/tool/investigation-board/panzoom.ts +24 -0
  42. package/src/tool/investigation-board/renderer.ts +256 -0
  43. package/src/tool/investigation-board/seo.astro +16 -0
  44. package/src/tool/investigation-board/stateManager.ts +126 -0
  45. package/src/tool/investigation-board/types.ts +26 -0
  46. package/src/tools.ts +3 -0
@@ -0,0 +1,16 @@
1
+ import { viewportEl, areaEl, state } from './dom';
2
+
3
+ export function adjustBoardSize(): void {
4
+ const viewW = viewportEl.clientWidth || 800;
5
+ const viewH = viewportEl.clientHeight || 500;
6
+ let maxX = viewW;
7
+ let maxY = viewH;
8
+ state.nodes.forEach((n) => {
9
+ const right = n.x + 260;
10
+ const bottom = n.y + 180;
11
+ if (right > maxX) maxX = right;
12
+ if (bottom > maxY) maxY = bottom;
13
+ });
14
+ areaEl.style.width = `${maxX + 200}px`;
15
+ areaEl.style.height = `${maxY + 200}px`;
16
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ createNode,
4
+ updateNode,
5
+ deleteNode,
6
+ createConnection,
7
+ updateConnection,
8
+ deleteConnection,
9
+ filterNodes,
10
+ getHighlightPath,
11
+ } from './logic';
12
+ import type { BoardNode, BoardConnection } from './types';
13
+
14
+ describe('Investigation Board Logic', () => {
15
+ it('should create a node with unique id and category', () => {
16
+ const node = createNode({ name: 'Test Card', category: 'clue', x: 10, y: 20, color: '#ef4444' });
17
+ expect(node.id).toBeDefined();
18
+ expect(node.name).toBe('Test Card');
19
+ expect(node.category).toBe('clue');
20
+ expect(node.x).toBe(10);
21
+ expect(node.y).toBe(20);
22
+ expect(node.color).toBe('#ef4444');
23
+ });
24
+
25
+ it('should update a node name and coordinates', () => {
26
+ const node = createNode({ name: 'Original', category: 'item', x: 10, y: 20, color: '#f59e0b' });
27
+ const nodes = [node];
28
+ const updated = updateNode(nodes, node.id, { name: 'New Name', x: 50 });
29
+ expect(updated[0].name).toBe('New Name');
30
+ expect(updated[0].x).toBe(50);
31
+ expect(updated[0].y).toBe(20);
32
+ });
33
+
34
+ it('should delete a node and clean up its connections', () => {
35
+ const n1 = createNode({ name: 'N1', category: 'character', x: 0, y: 0, color: '#000' });
36
+ const n2 = createNode({ name: 'N2', category: 'clue', x: 0, y: 0, color: '#000' });
37
+ const conn = createConnection(n1.id, n2.id, 'links', '#000');
38
+
39
+ const result = deleteNode([n1, n2], [conn], n1.id);
40
+ expect(result.nodes).toHaveLength(1);
41
+ expect(result.nodes[0].id).toBe(n2.id);
42
+ expect(result.connections).toHaveLength(0);
43
+ });
44
+
45
+ it('should create and delete a connection', () => {
46
+ const conn1 = createConnection('n1', 'n2', 'rel1', '#fff');
47
+ const conn2 = createConnection('n2', 'n3', 'rel2', '#fff');
48
+ const list = [conn1, conn2];
49
+
50
+ const updated = updateConnection(list, conn1.id, { label: 'updated label' });
51
+ expect(updated[0].label).toBe('updated label');
52
+
53
+ const filtered = deleteConnection(list, conn1.id);
54
+ expect(filtered).toHaveLength(1);
55
+ expect(filtered[0].id).toBe(conn2.id);
56
+ });
57
+
58
+ it('should filter nodes based on category and search query', () => {
59
+ const n1: BoardNode = {
60
+ id: '1',
61
+ name: 'Agent Cooper',
62
+ category: 'character',
63
+ description: 'FBI Special Agent',
64
+ notes: 'Loves cherry pie',
65
+ tags: ['fbi', 'investigator'],
66
+ color: '#000',
67
+ x: 0,
68
+ y: 0,
69
+ };
70
+ const n2: BoardNode = {
71
+ id: '2',
72
+ name: 'Blue Rose Case',
73
+ category: 'clue',
74
+ description: 'Mysterious case file',
75
+ notes: 'Classified',
76
+ tags: ['clue', 'secret'],
77
+ color: '#000',
78
+ x: 0,
79
+ y: 0,
80
+ };
81
+
82
+ const list = [n1, n2];
83
+
84
+ const filteredCategory = filterNodes(list, '', 'character');
85
+ expect(filteredCategory).toHaveLength(1);
86
+ expect(filteredCategory[0].name).toBe('Agent Cooper');
87
+
88
+ const filteredSearch = filterNodes(list, 'cherry', 'all');
89
+ expect(filteredSearch).toHaveLength(1);
90
+ expect(filteredSearch[0].name).toBe('Agent Cooper');
91
+ });
92
+
93
+ it('should retrieve highlight paths connected to a specific node', () => {
94
+ const conn1: BoardConnection = { id: 'c1', fromId: 'n1', toId: 'n2', label: '1', color: '#000' };
95
+ const conn2: BoardConnection = { id: 'c2', fromId: 'n2', toId: 'n3', label: '2', color: '#000' };
96
+ const connections = [conn1, conn2];
97
+
98
+ const highlight = getHighlightPath(connections, 'n2');
99
+ expect(highlight.nodeIds.has('n1')).toBe(true);
100
+ expect(highlight.nodeIds.has('n3')).toBe(true);
101
+ expect(highlight.nodeIds.has('n2')).toBe(true);
102
+ expect(highlight.connectionIds.has('c1')).toBe(true);
103
+ expect(highlight.connectionIds.has('c2')).toBe(true);
104
+ });
105
+ });
@@ -0,0 +1,106 @@
1
+ import type { BoardNode, BoardConnection, NodeCategory } from './types';
2
+
3
+ export function createNode(options: {
4
+ name: string;
5
+ category: NodeCategory;
6
+ x: number;
7
+ y: number;
8
+ color: string;
9
+ }): BoardNode {
10
+ return {
11
+ id: Math.random().toString(36).substring(2, 9),
12
+ name: options.name,
13
+ category: options.category,
14
+ description: '',
15
+ notes: '',
16
+ tags: [],
17
+ color: options.color,
18
+ x: options.x,
19
+ y: options.y,
20
+ };
21
+ }
22
+
23
+ export function updateNode(
24
+ nodes: BoardNode[],
25
+ id: string,
26
+ updates: Partial<Omit<BoardNode, 'id'>>,
27
+ ): BoardNode[] {
28
+ return nodes.map((n) => (n.id === id ? { ...n, ...updates } : n));
29
+ }
30
+
31
+ export function deleteNode(
32
+ nodes: BoardNode[],
33
+ connections: BoardConnection[],
34
+ id: string,
35
+ ): { nodes: BoardNode[]; connections: BoardConnection[] } {
36
+ return {
37
+ nodes: nodes.filter((n) => n.id !== id),
38
+ connections: connections.filter((c) => c.fromId !== id && c.toId !== id),
39
+ };
40
+ }
41
+
42
+ export function createConnection(
43
+ fromId: string,
44
+ toId: string,
45
+ label: string,
46
+ color: string,
47
+ ): BoardConnection {
48
+ return {
49
+ id: Math.random().toString(36).substring(2, 9),
50
+ fromId,
51
+ toId,
52
+ label,
53
+ color,
54
+ };
55
+ }
56
+
57
+ export function updateConnection(
58
+ connections: BoardConnection[],
59
+ id: string,
60
+ updates: Partial<Omit<BoardConnection, 'id'>>,
61
+ ): BoardConnection[] {
62
+ return connections.map((c) => (c.id === id ? { ...c, ...updates } : c));
63
+ }
64
+
65
+ export function deleteConnection(
66
+ connections: BoardConnection[],
67
+ id: string,
68
+ ): BoardConnection[] {
69
+ return connections.filter((c) => c.id !== id);
70
+ }
71
+
72
+ export function filterNodes(
73
+ nodes: BoardNode[],
74
+ search: string,
75
+ category: string,
76
+ ): BoardNode[] {
77
+ const query = search.toLowerCase().trim();
78
+ return nodes.filter((n) => {
79
+ const matchesCategory = !category || category === 'all' || n.category === category;
80
+ const matchesSearch =
81
+ !query ||
82
+ n.name.toLowerCase().includes(query) ||
83
+ n.description.toLowerCase().includes(query) ||
84
+ n.notes.toLowerCase().includes(query) ||
85
+ n.tags.some((t) => t.toLowerCase().includes(query));
86
+ return matchesCategory && matchesSearch;
87
+ });
88
+ }
89
+
90
+ export function getHighlightPath(
91
+ connections: BoardConnection[],
92
+ nodeId: string,
93
+ ): { nodeIds: Set<string>; connectionIds: Set<string> } {
94
+ const nodeIds = new Set<string>([nodeId]);
95
+ const connectionIds = new Set<string>();
96
+ for (const c of connections) {
97
+ if (c.fromId === nodeId) {
98
+ nodeIds.add(c.toId);
99
+ connectionIds.add(c.id);
100
+ } else if (c.toId === nodeId) {
101
+ nodeIds.add(c.fromId);
102
+ connectionIds.add(c.id);
103
+ }
104
+ }
105
+ return { nodeIds, connectionIds };
106
+ }
@@ -0,0 +1,24 @@
1
+ import Panzoom from '@panzoom/panzoom';
2
+ import { areaEl, viewportEl, zoomValueEl, state } from './dom';
3
+
4
+ export const panzoomInstance = Panzoom(areaEl, {
5
+ maxScale: 1.8,
6
+ minScale: 0.3,
7
+ excludeClass: 'panzoom-exclude',
8
+ handleStartEvent: (event: Event) => {
9
+ const target = event.target as HTMLElement;
10
+ if (target.closest('.panzoom-exclude') || target.closest('.board-node')) {
11
+ return;
12
+ }
13
+ event.preventDefault();
14
+ event.stopPropagation();
15
+ },
16
+ });
17
+
18
+ viewportEl.addEventListener('wheel', panzoomInstance.zoomWithWheel);
19
+
20
+ areaEl.addEventListener('panzoomchange', (e: Event) => {
21
+ const detail = (e as CustomEvent).detail;
22
+ state.zoomScale = detail.scale;
23
+ zoomValueEl.textContent = `${Math.round(state.zoomScale * 100)}%`;
24
+ });
@@ -0,0 +1,256 @@
1
+ import type { BoardNode, BoardConnection } from './types';
2
+ import {
3
+ state,
4
+ mainCard,
5
+ nodesLayer,
6
+ svgEl,
7
+ connBanner,
8
+ } from './dom';
9
+ import { adjustBoardSize } from './layout';
10
+ import { filterNodes, getHighlightPath, deleteConnection } from './logic';
11
+ import { startLongPress } from './boardContextMenu';
12
+ import { openDrawer } from './drawer';
13
+ import { openConnModal } from './connectionModal';
14
+ import { saveState } from './stateManager';
15
+
16
+ interface HighlightPath {
17
+ nodeIds: Set<string>;
18
+ connectionIds: Set<string>;
19
+ }
20
+
21
+ interface ConnectionGroupOptions {
22
+ c: BoardConnection;
23
+ x1: number;
24
+ y1: number;
25
+ targetX: number;
26
+ targetY: number;
27
+ highlight: HighlightPath | null;
28
+ }
29
+
30
+ interface CenterCoords {
31
+ x: number;
32
+ y: number;
33
+ }
34
+
35
+ function t(key: string): string {
36
+ if (!mainCard) return '';
37
+ try {
38
+ const ui = JSON.parse(mainCard.getAttribute('data-ui') || '{}');
39
+ return ui[key] || '';
40
+ } catch {
41
+ return '';
42
+ }
43
+ }
44
+
45
+ function getNodeTagsHtml(tags: string[]): string {
46
+ return tags
47
+ .filter((tg) => tg.trim().length > 0)
48
+ .map((tg) => `<span class="board-node-tag">${tg}</span>`)
49
+ .join('');
50
+ }
51
+
52
+ function getNodeNotesHtml(notes: string): string {
53
+ const notesHtml = notes
54
+ .split('\n')
55
+ .map((line) => line.trim())
56
+ .filter((line) => line.length > 0)
57
+ .map((line) => `<li>${line}</li>`)
58
+ .join('');
59
+ return notesHtml ? `<ul class="board-node-notes">${notesHtml}</ul>` : '';
60
+ }
61
+
62
+ function createCardMarkup(n: BoardNode, tagsHtml: string, notesHtml: string): string {
63
+ return `
64
+ <div class="board-node-header">
65
+ <span class="node-category-badge category-${n.category}">${t(n.category) || n.category}</span>
66
+ <div class="node-actions">
67
+ <button type="button" class="node-action-link btn-link-node" data-id="${n.id}" aria-label="Link">
68
+ <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="display: block;"><path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3 M9 17H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3 M8 12h8"/></svg>
69
+ </button>
70
+ <button type="button" class="node-action-link btn-edit-node" data-id="${n.id}" aria-label="Edit">
71
+ <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="display: block;"><path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
72
+ </button>
73
+ </div>
74
+ </div>
75
+ <div class="board-node-title">${n.name}</div>
76
+ <div class="board-node-desc">${n.description || ''}</div>
77
+ ${notesHtml}
78
+ <div class="board-node-tags">${tagsHtml}</div>
79
+ `;
80
+ }
81
+
82
+ function handleCardClick(nodeId: string): void {
83
+ if (state.quickLinkSourceId) {
84
+ if (state.quickLinkSourceId !== nodeId) {
85
+ const fromId = state.quickLinkSourceId;
86
+ state.quickLinkSourceId = null;
87
+ connBanner.classList.remove('active');
88
+ openConnModal(fromId, nodeId);
89
+ }
90
+ return;
91
+ }
92
+ state.highlightedNodeId = state.highlightedNodeId === nodeId ? null : nodeId;
93
+ renderBoard();
94
+ }
95
+
96
+ function bindCardEvents(card: HTMLElement, n: BoardNode): void {
97
+ card.addEventListener('pointerdown', (e) => {
98
+ if ((e.target as HTMLElement).closest('.node-action-link')) return;
99
+ startLongPress(e, n.id);
100
+ });
101
+ card.addEventListener('click', (e) => {
102
+ e.stopPropagation();
103
+ handleCardClick(n.id);
104
+ });
105
+ card.addEventListener('dblclick', (e) => {
106
+ e.stopPropagation();
107
+ openDrawer(n);
108
+ });
109
+ card.querySelector('.btn-edit-node')?.addEventListener('click', (e) => {
110
+ e.stopPropagation();
111
+ openDrawer(n);
112
+ });
113
+ card.querySelector('.btn-link-node')?.addEventListener('click', (e) => {
114
+ e.stopPropagation();
115
+ state.quickLinkSourceId = n.id;
116
+ connBanner.classList.add('active');
117
+ });
118
+ }
119
+
120
+ function renderNodeCard(n: BoardNode, highlight: HighlightPath | null): void {
121
+ const card = document.createElement('div');
122
+ card.className = 'board-node panzoom-exclude';
123
+ card.id = `node-${n.id}`;
124
+ card.style.left = `${n.x}px`;
125
+ card.style.top = `${n.y}px`;
126
+ card.style.borderColor = n.color;
127
+
128
+ if (highlight) {
129
+ if (highlight.nodeIds.has(n.id)) {
130
+ if (n.id === state.highlightedNodeId) {
131
+ card.classList.add('highlight-source');
132
+ }
133
+ } else {
134
+ card.classList.add('dimmed');
135
+ }
136
+ }
137
+
138
+ card.innerHTML = createCardMarkup(n, getNodeTagsHtml(n.tags), getNodeNotesHtml(n.notes));
139
+ bindCardEvents(card, n);
140
+ nodesLayer.appendChild(card);
141
+ }
142
+
143
+ function handleConnectionClick(c: BoardConnection): void {
144
+ if (confirm(`Delete connection "${c.label}"?`)) {
145
+ state.connections = deleteConnection(state.connections, c.id);
146
+ saveState();
147
+ renderBoard();
148
+ }
149
+ }
150
+
151
+ function createConnectionText(c: BoardConnection, midX: number, midY: number, highlight: HighlightPath | null): SVGTextElement {
152
+ const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
153
+ text.setAttribute('x', midX.toString());
154
+ text.setAttribute('y', midY.toString());
155
+ text.setAttribute('class', 'connection-text');
156
+ text.setAttribute('fill', 'var(--text-base)');
157
+ text.textContent = c.label;
158
+
159
+ if (highlight && !highlight.connectionIds.has(c.id)) {
160
+ text.style.opacity = '0.1';
161
+ }
162
+ return text;
163
+ }
164
+
165
+ function createConnectionGroup(opts: ConnectionGroupOptions): SVGElement {
166
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
167
+ g.style.cursor = 'pointer';
168
+
169
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
170
+ path.setAttribute('d', `M ${opts.x1} ${opts.y1} L ${opts.targetX} ${opts.targetY}`);
171
+ path.setAttribute('stroke', opts.c.color);
172
+ path.setAttribute('class', 'connection-path');
173
+ path.setAttribute('marker-end', 'url(#arrowhead)');
174
+
175
+ if (opts.highlight) {
176
+ if (opts.highlight.connectionIds.has(opts.c.id)) {
177
+ path.classList.add('highlighted');
178
+ } else {
179
+ path.style.opacity = '0.1';
180
+ }
181
+ }
182
+
183
+ g.appendChild(path);
184
+ g.appendChild(createConnectionText(opts.c, (opts.x1 + opts.targetX) / 2, (opts.y1 + opts.targetY) / 2, opts.highlight));
185
+ g.addEventListener('click', (e) => {
186
+ e.stopPropagation();
187
+ handleConnectionClick(opts.c);
188
+ });
189
+ return g;
190
+ }
191
+
192
+ function getNodeCenter(n: BoardNode): CenterCoords {
193
+ const el = document.getElementById(`node-${n.id}`);
194
+ const w = el ? el.offsetWidth || 240 : 240;
195
+ const h = el ? el.offsetHeight || 120 : 120;
196
+ return {
197
+ x: n.x + w / 2,
198
+ y: n.y + h / 2,
199
+ };
200
+ }
201
+
202
+ function getConnectionCoords(c: BoardConnection): { x1: number; y1: number; x2: number; y2: number } | null {
203
+ const fromNode = state.nodes.find((n) => n.id === c.fromId);
204
+ const toNode = state.nodes.find((n) => n.id === c.toId);
205
+ if (!fromNode || !toNode) return null;
206
+
207
+ const c1 = getNodeCenter(fromNode);
208
+ const c2 = getNodeCenter(toNode);
209
+ return {
210
+ x1: c1.x,
211
+ y1: c1.y,
212
+ x2: c2.x,
213
+ y2: c2.y,
214
+ };
215
+ }
216
+
217
+ function drawConnectionLine(c: BoardConnection, highlight: HighlightPath | null): void {
218
+ const coords = getConnectionCoords(c);
219
+ if (!coords) return;
220
+
221
+ const dx = coords.x2 - coords.x1;
222
+ const dy = coords.y2 - coords.y1;
223
+ const distance = Math.sqrt(dx * dx + dy * dy);
224
+ if (distance === 0) return;
225
+
226
+ const stopDistance = 130;
227
+ const ratio = Math.max(0, (distance - stopDistance) / distance);
228
+ const targetX = coords.x1 + dx * ratio;
229
+ const targetY = coords.y1 + dy * ratio;
230
+
231
+ const g = createConnectionGroup({
232
+ c,
233
+ x1: coords.x1,
234
+ y1: coords.y1,
235
+ targetX,
236
+ targetY,
237
+ highlight,
238
+ });
239
+ svgEl.appendChild(g);
240
+ }
241
+
242
+ export function drawConnections(highlight: HighlightPath | null): void {
243
+ while (svgEl.lastChild && svgEl.lastChild.nodeName !== 'defs') {
244
+ svgEl.removeChild(svgEl.lastChild);
245
+ }
246
+ state.connections.forEach((c) => drawConnectionLine(c, highlight));
247
+ }
248
+
249
+ export function renderBoard(): void {
250
+ adjustBoardSize();
251
+ nodesLayer.innerHTML = '';
252
+ const filtered = filterNodes(state.nodes, state.currentSearchQuery, state.currentCategoryFilter);
253
+ const highlight = state.highlightedNodeId ? getHighlightPath(state.connections, state.highlightedNodeId) : null;
254
+ filtered.forEach((n) => renderNodeCard(n, highlight));
255
+ drawConnections(highlight);
256
+ }
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { investigationBoard } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props;
11
+ const loader = investigationBoard.i18n[locale] || investigationBoard.i18n.en;
12
+ const content = await loader?.();
13
+ if (!content) return null;
14
+ ---
15
+
16
+ {content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,126 @@
1
+ import type { BoardConnection, BoardNode } from './types';
2
+ import {
3
+ state,
4
+ STORAGE_KEY,
5
+ CURRENT_ID_KEY,
6
+ DATA_KEY_PREFIX,
7
+ boardSelect,
8
+ } from './dom';
9
+
10
+ const DEFAULT_NODES: BoardNode[] = [
11
+ {
12
+ id: 'n1',
13
+ name: 'Detective Miller',
14
+ category: 'character',
15
+ description: 'Lead investigator of the warehouse case.',
16
+ notes: 'Thinks the fire was deliberate.',
17
+ tags: ['police', 'lead'],
18
+ color: '#3b82f6',
19
+ x: 50,
20
+ y: 50,
21
+ },
22
+ {
23
+ id: 'n2',
24
+ name: 'Footprints',
25
+ category: 'clue',
26
+ description: 'Found outside the back door.',
27
+ notes: 'Size 11 combat boots.',
28
+ tags: ['clue', 'physical'],
29
+ color: '#ef4444',
30
+ x: 400,
31
+ y: 50,
32
+ },
33
+ {
34
+ id: 'n3',
35
+ name: 'Warehouse',
36
+ category: 'location',
37
+ description: 'Abandoned docks storage unit.',
38
+ notes: 'Burned down at midnight.',
39
+ tags: ['location', 'scene'],
40
+ color: '#10b981',
41
+ x: 220,
42
+ y: 280,
43
+ },
44
+ ];
45
+
46
+ const DEFAULT_CONNECTIONS: BoardConnection[] = [
47
+ {
48
+ id: 'c1',
49
+ fromId: 'n1',
50
+ toId: 'n3',
51
+ label: 'Investigating',
52
+ color: '#3b82f6',
53
+ },
54
+ {
55
+ id: 'c2',
56
+ fromId: 'n2',
57
+ toId: 'n3',
58
+ label: 'Found at',
59
+ color: '#ef4444',
60
+ },
61
+ ];
62
+
63
+ export function saveState(): void {
64
+ localStorage.setItem(
65
+ DATA_KEY_PREFIX + state.currentBoardId,
66
+ JSON.stringify({ nodes: state.nodes, connections: state.connections })
67
+ );
68
+ }
69
+
70
+ export function loadBoardsList(): void {
71
+ const data = localStorage.getItem(STORAGE_KEY);
72
+ if (data) {
73
+ try {
74
+ state.boardsList = JSON.parse(data);
75
+ } catch {
76
+ state.boardsList = [];
77
+ }
78
+ }
79
+ if (state.boardsList.length === 0) {
80
+ state.boardsList = [{ id: 'default', name: 'Main Investigation' }];
81
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state.boardsList));
82
+ }
83
+ }
84
+
85
+ export function saveBoardsList(): void {
86
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state.boardsList));
87
+ }
88
+
89
+ export function updateBoardSelect(): void {
90
+ boardSelect.innerHTML = '';
91
+ state.boardsList.forEach((b) => {
92
+ const opt = document.createElement('option');
93
+ opt.value = b.id;
94
+ opt.textContent = b.name;
95
+ if (b.id === state.currentBoardId) {
96
+ opt.selected = true;
97
+ }
98
+ boardSelect.appendChild(opt);
99
+ });
100
+ }
101
+
102
+ function loadDefaultState(): void {
103
+ state.nodes = DEFAULT_NODES;
104
+ state.connections = DEFAULT_CONNECTIONS;
105
+ saveState();
106
+ }
107
+
108
+ export function loadBoardState(boardId: string): void {
109
+ state.currentBoardId = boardId;
110
+ localStorage.setItem(CURRENT_ID_KEY, boardId);
111
+ const data = localStorage.getItem(DATA_KEY_PREFIX + boardId);
112
+ if (data) {
113
+ try {
114
+ const parsed = JSON.parse(data);
115
+ state.nodes = parsed.nodes || [];
116
+ state.connections = parsed.connections || [];
117
+ return;
118
+ } catch {}
119
+ }
120
+ if (boardId === 'default') {
121
+ loadDefaultState();
122
+ } else {
123
+ state.nodes = [];
124
+ state.connections = [];
125
+ }
126
+ }
@@ -0,0 +1,26 @@
1
+ export type NodeCategory = 'character' | 'clue' | 'location' | 'item' | 'custom';
2
+
3
+ export interface BoardNode {
4
+ id: string;
5
+ name: string;
6
+ category: NodeCategory;
7
+ description: string;
8
+ notes: string;
9
+ tags: string[];
10
+ color: string;
11
+ x: number;
12
+ y: number;
13
+ }
14
+
15
+ export interface BoardConnection {
16
+ id: string;
17
+ fromId: string;
18
+ toId: string;
19
+ label: string;
20
+ color: string;
21
+ }
22
+
23
+ export interface BoardState {
24
+ nodes: BoardNode[];
25
+ connections: BoardConnection[];
26
+ }
package/src/tools.ts CHANGED
@@ -7,6 +7,7 @@ import { SCORE_TRACKER_TOOL } from './tool/score-tracker';
7
7
  import { INITIATIVE_TRACKER_TOOL } from './tool/rpg-initiative-tracker';
8
8
  import { FANTASY_RUNES_TRANSLATOR_TOOL } from './tool/fantasy-runes-translator';
9
9
  import { DECISION_WHEEL_TOOL } from './tool/decision-wheel';
10
+ import { INVESTIGATION_BOARD_TOOL } from './tool/investigation-board';
10
11
 
11
12
  export const ALL_TOOLS: ToolDefinition[] = [
12
13
  DICE_ROLLER_SIMULATOR_TOOL,
@@ -16,4 +17,6 @@ export const ALL_TOOLS: ToolDefinition[] = [
16
17
  INITIATIVE_TRACKER_TOOL,
17
18
  FANTASY_RUNES_TRANSLATOR_TOOL,
18
19
  DECISION_WHEEL_TOOL,
20
+ INVESTIGATION_BOARD_TOOL,
19
21
  ];
22
+