Implement interactive pushpins and drag-to-connect red yarn webbing for Evidence Board
This commit is contained in:
1 parent
760ba2ff53
commit
ac0c3bdf79
5 files changed
+515
-148
No files matched your search
+4
-1
@@ -83,6 +83,7 @@ function readDeskState() {
|
|||||||
cards: desk.cards || [],
|
cards: desk.cards || [],
|
||||||
props: desk.props || [],
|
props: desk.props || [],
|
||||||
strokes: desk.strokes || [],
|
strokes: desk.strokes || [],
|
||||||
|
yarnLinks: desk.yarnLinks || [],
|
||||||
settings: desk.settings || {}
|
settings: desk.settings || {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -155,7 +156,7 @@ app.get('/api/desk', (_req, res) => {
|
|||||||
|
|
||||||
// Save desk state
|
// Save desk state
|
||||||
app.post('/api/desk', (req, res) => {
|
app.post('/api/desk', (req, res) => {
|
||||||
const { cards, props, strokes, settings, activeBoard, boards, originClientId } = req.body;
|
const { cards, props, strokes, yarnLinks, settings, activeBoard, boards, originClientId } = req.body;
|
||||||
|
|
||||||
if (!Array.isArray(cards)) {
|
if (!Array.isArray(cards)) {
|
||||||
return res.status(400).json({ success: false, error: 'Invalid desk state: cards required' });
|
return res.status(400).json({ success: false, error: 'Invalid desk state: cards required' });
|
||||||
@@ -167,6 +168,7 @@ app.post('/api/desk', (req, res) => {
|
|||||||
cards,
|
cards,
|
||||||
props: Array.isArray(props) ? props : [],
|
props: Array.isArray(props) ? props : [],
|
||||||
strokes: Array.isArray(strokes) ? strokes : [],
|
strokes: Array.isArray(strokes) ? strokes : [],
|
||||||
|
yarnLinks: Array.isArray(yarnLinks) ? yarnLinks : (updatedBoards[currentBoard]?.yarnLinks || []),
|
||||||
settings: settings || {}
|
settings: settings || {}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,6 +176,7 @@ app.post('/api/desk', (req, res) => {
|
|||||||
cards,
|
cards,
|
||||||
props: Array.isArray(props) ? props : [],
|
props: Array.isArray(props) ? props : [],
|
||||||
strokes: Array.isArray(strokes) ? strokes : [],
|
strokes: Array.isArray(strokes) ? strokes : [],
|
||||||
|
yarnLinks: Array.isArray(yarnLinks) ? yarnLinks : [],
|
||||||
settings: settings || {},
|
settings: settings || {},
|
||||||
activeBoard: currentBoard,
|
activeBoard: currentBoard,
|
||||||
boards: updatedBoards,
|
boards: updatedBoards,
|
||||||
|
|||||||
+167
-7
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { CardItem, DeskProp, DeskSettings, InkStroke, SyncStatus, DeskTexture, BoardsMap } from './types';
|
import { CardItem, DeskProp, DeskSettings, InkStroke, SyncStatus, DeskTexture, BoardsMap, YarnLink } from './types';
|
||||||
import { generateProceduralCards, SAMPLE_PRESETS } from './utils/proceduralCards';
|
import { generateProceduralCards, SAMPLE_PRESETS } from './utils/proceduralCards';
|
||||||
import { soundEngine } from './utils/sound';
|
import { soundEngine } from './utils/sound';
|
||||||
import { TopBar } from './components/TopBar';
|
import { TopBar } from './components/TopBar';
|
||||||
@@ -36,6 +36,15 @@ export const App: React.FC = () => {
|
|||||||
const [cards, setCards] = useState<CardItem[]>([]);
|
const [cards, setCards] = useState<CardItem[]>([]);
|
||||||
const [propsList, setPropsList] = useState<DeskProp[]>(INITIAL_PROPS);
|
const [propsList, setPropsList] = useState<DeskProp[]>(INITIAL_PROPS);
|
||||||
const [strokes, setStrokes] = useState<InkStroke[]>([]);
|
const [strokes, setStrokes] = useState<InkStroke[]>([]);
|
||||||
|
const [yarnLinks, setYarnLinks] = useState<YarnLink[]>([]);
|
||||||
|
const [yarnDrag, setYarnDrag] = useState<{
|
||||||
|
fromCardId: string;
|
||||||
|
fromX: number;
|
||||||
|
fromY: number;
|
||||||
|
currentX: number;
|
||||||
|
currentY: number;
|
||||||
|
hoveredCardId: string | null;
|
||||||
|
} | null>(null);
|
||||||
const [maxZIndex, setMaxZIndex] = useState(50);
|
const [maxZIndex, setMaxZIndex] = useState(50);
|
||||||
const boardsRef = useRef<BoardsMap>({});
|
const boardsRef = useRef<BoardsMap>({});
|
||||||
|
|
||||||
@@ -67,7 +76,8 @@ export const App: React.FC = () => {
|
|||||||
currentSettings: DeskSettings,
|
currentSettings: DeskSettings,
|
||||||
immediate = false,
|
immediate = false,
|
||||||
targetBoard?: DeskTexture,
|
targetBoard?: DeskTexture,
|
||||||
targetBoards?: BoardsMap
|
targetBoards?: BoardsMap,
|
||||||
|
currentYarnLinks?: YarnLink[]
|
||||||
) => {
|
) => {
|
||||||
if (!isLoadedRef.current) return;
|
if (!isLoadedRef.current) return;
|
||||||
|
|
||||||
@@ -76,12 +86,14 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentBoard = targetBoard || currentSettings.deskTexture || 'oak';
|
const currentBoard = targetBoard || currentSettings.deskTexture || 'oak';
|
||||||
|
const activeYarnLinks = currentYarnLinks !== undefined ? currentYarnLinks : yarnLinks;
|
||||||
const updatedBoards: BoardsMap = targetBoards || {
|
const updatedBoards: BoardsMap = targetBoards || {
|
||||||
...boardsRef.current,
|
...boardsRef.current,
|
||||||
[currentBoard]: {
|
[currentBoard]: {
|
||||||
cards: currentCards,
|
cards: currentCards,
|
||||||
props: currentProps,
|
props: currentProps,
|
||||||
strokes: currentStrokes,
|
strokes: currentStrokes,
|
||||||
|
yarnLinks: activeYarnLinks,
|
||||||
settings: currentSettings
|
settings: currentSettings
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -90,6 +102,7 @@ export const App: React.FC = () => {
|
|||||||
cards: currentCards,
|
cards: currentCards,
|
||||||
props: currentProps,
|
props: currentProps,
|
||||||
strokes: currentStrokes,
|
strokes: currentStrokes,
|
||||||
|
yarnLinks: activeYarnLinks,
|
||||||
settings: currentSettings
|
settings: currentSettings
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -104,6 +117,7 @@ export const App: React.FC = () => {
|
|||||||
cards: currentCards,
|
cards: currentCards,
|
||||||
props: currentProps,
|
props: currentProps,
|
||||||
strokes: currentStrokes,
|
strokes: currentStrokes,
|
||||||
|
yarnLinks: activeYarnLinks,
|
||||||
activeBoard: currentBoard,
|
activeBoard: currentBoard,
|
||||||
boards: updatedBoards,
|
boards: updatedBoards,
|
||||||
settings: currentSettings,
|
settings: currentSettings,
|
||||||
@@ -162,6 +176,22 @@ export const App: React.FC = () => {
|
|||||||
setCards(currentBoardData.cards || []);
|
setCards(currentBoardData.cards || []);
|
||||||
if (currentBoardData.props) setPropsList(currentBoardData.props);
|
if (currentBoardData.props) setPropsList(currentBoardData.props);
|
||||||
if (currentBoardData.strokes) setStrokes(currentBoardData.strokes);
|
if (currentBoardData.strokes) setStrokes(currentBoardData.strokes);
|
||||||
|
if (Array.isArray(currentBoardData.yarnLinks)) {
|
||||||
|
setYarnLinks(currentBoardData.yarnLinks);
|
||||||
|
} else if (activeBoard === 'evidence-board' && currentBoardData.cards && currentBoardData.cards.length >= 2) {
|
||||||
|
setYarnLinks([
|
||||||
|
{ id: 'ev-link-1', fromId: 'ev-photo-1', toId: 'ev-tag-1' },
|
||||||
|
{ id: 'ev-link-2', fromId: 'ev-tag-1', toId: 'ev-memo' },
|
||||||
|
{ id: 'ev-link-3', fromId: 'ev-memo', toId: 'ev-photo-2' },
|
||||||
|
{ id: 'ev-link-4', fromId: 'ev-photo-1', toId: 'ev-ticket' },
|
||||||
|
{ id: 'ev-link-5', fromId: 'ev-ticket', toId: 'ev-check' },
|
||||||
|
{ id: 'ev-link-6', fromId: 'ev-check', toId: 'ev-receipt' },
|
||||||
|
{ id: 'ev-link-7', fromId: 'ev-receipt', toId: 'ev-graph' },
|
||||||
|
{ id: 'ev-link-8', fromId: 'ev-memo', toId: 'ev-graph' }
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
setYarnLinks([]);
|
||||||
|
}
|
||||||
|
|
||||||
const boardSettings = currentBoardData.settings || desk.settings || {};
|
const boardSettings = currentBoardData.settings || desk.settings || {};
|
||||||
const isMobile = window.innerWidth < 768;
|
const isMobile = window.innerWidth < 768;
|
||||||
@@ -202,14 +232,14 @@ export const App: React.FC = () => {
|
|||||||
);
|
);
|
||||||
setCards(initialCards);
|
setCards(initialCards);
|
||||||
const initialBoards: BoardsMap = {
|
const initialBoards: BoardsMap = {
|
||||||
oak: { cards: initialCards, props: INITIAL_PROPS, strokes: [], settings }
|
oak: { cards: initialCards, props: INITIAL_PROPS, strokes: [], yarnLinks: [], settings }
|
||||||
};
|
};
|
||||||
boardsRef.current = initialBoards;
|
boardsRef.current = initialBoards;
|
||||||
setMaxZIndex(initialCards.length + 20);
|
setMaxZIndex(initialCards.length + 20);
|
||||||
setSyncStatus('synced');
|
setSyncStatus('synced');
|
||||||
isLoadedRef.current = true;
|
isLoadedRef.current = true;
|
||||||
|
|
||||||
saveToServer(initialCards, INITIAL_PROPS, [], settings, true, 'oak', initialBoards);
|
saveToServer(initialCards, INITIAL_PROPS, [], settings, true, 'oak', initialBoards, []);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -244,6 +274,11 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
if (Array.isArray(payload.desk.props)) setPropsList(payload.desk.props);
|
if (Array.isArray(payload.desk.props)) setPropsList(payload.desk.props);
|
||||||
if (Array.isArray(payload.desk.strokes)) setStrokes(payload.desk.strokes);
|
if (Array.isArray(payload.desk.strokes)) setStrokes(payload.desk.strokes);
|
||||||
|
if (Array.isArray(payload.desk.yarnLinks)) {
|
||||||
|
setYarnLinks(payload.desk.yarnLinks);
|
||||||
|
} else if (payload.desk.boards && payload.desk.boards[activeBoard]?.yarnLinks) {
|
||||||
|
setYarnLinks(payload.desk.boards[activeBoard].yarnLinks || []);
|
||||||
|
}
|
||||||
const boardSettings = (payload.desk.boards && payload.desk.boards[activeBoard]?.settings) || payload.desk.settings;
|
const boardSettings = (payload.desk.boards && payload.desk.boards[activeBoard]?.settings) || payload.desk.settings;
|
||||||
if (boardSettings) {
|
if (boardSettings) {
|
||||||
setSettings((prev) => ({
|
setSettings((prev) => ({
|
||||||
@@ -301,6 +336,7 @@ export const App: React.FC = () => {
|
|||||||
cards,
|
cards,
|
||||||
props: propsList,
|
props: propsList,
|
||||||
strokes,
|
strokes,
|
||||||
|
yarnLinks,
|
||||||
settings: prev
|
settings: prev
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -316,6 +352,7 @@ export const App: React.FC = () => {
|
|||||||
cards: seed,
|
cards: seed,
|
||||||
props: INITIAL_PROPS,
|
props: INITIAL_PROPS,
|
||||||
strokes: [],
|
strokes: [],
|
||||||
|
yarnLinks: [],
|
||||||
settings: { deskTexture: 'oak', zoom: w < 768 ? 0.65 : 1.0, lightAngle: 225, grain: 0.7 }
|
settings: { deskTexture: 'oak', zoom: w < 768 ? 0.65 : 1.0, lightAngle: 225, grain: 0.7 }
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -347,10 +384,14 @@ export const App: React.FC = () => {
|
|||||||
soundEnabled: prev.soundEnabled
|
soundEnabled: prev.soundEnabled
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const nextYarnLinks = Array.isArray(nextBoardData.yarnLinks) ? nextBoardData.yarnLinks : [];
|
||||||
|
setYarnLinks(nextYarnLinks);
|
||||||
|
|
||||||
updatedBoards[nextDesk] = {
|
updatedBoards[nextDesk] = {
|
||||||
cards: nextBoardData.cards || [],
|
cards: nextBoardData.cards || [],
|
||||||
props: nextBoardData.props || INITIAL_PROPS,
|
props: nextBoardData.props || INITIAL_PROPS,
|
||||||
strokes: nextBoardData.strokes || [],
|
strokes: nextBoardData.strokes || [],
|
||||||
|
yarnLinks: nextYarnLinks,
|
||||||
settings: nextSettings
|
settings: nextSettings
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -366,7 +407,8 @@ export const App: React.FC = () => {
|
|||||||
nextSettings,
|
nextSettings,
|
||||||
true,
|
true,
|
||||||
nextDesk,
|
nextDesk,
|
||||||
updatedBoards
|
updatedBoards,
|
||||||
|
nextYarnLinks
|
||||||
);
|
);
|
||||||
|
|
||||||
soundEngine.rustle(nextSettings.soundEnabled);
|
soundEngine.rustle(nextSettings.soundEnabled);
|
||||||
@@ -424,16 +466,17 @@ export const App: React.FC = () => {
|
|||||||
cards: nextCards,
|
cards: nextCards,
|
||||||
props: propsList,
|
props: propsList,
|
||||||
strokes,
|
strokes,
|
||||||
|
yarnLinks,
|
||||||
settings: updated
|
settings: updated
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
boardsRef.current = updatedBoards;
|
boardsRef.current = updatedBoards;
|
||||||
|
|
||||||
saveToServer(nextCards, propsList, strokes, updated, false, currentDesk, updatedBoards);
|
saveToServer(nextCards, propsList, strokes, updated, false, currentDesk, updatedBoards, yarnLinks);
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[cards, propsList, strokes, saveToServer]
|
[cards, propsList, strokes, yarnLinks, saveToServer]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Re-shuffle cards (stratified positioning so cards never clump or run over each other)
|
// Re-shuffle cards (stratified positioning so cards never clump or run over each other)
|
||||||
@@ -657,6 +700,35 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
// Global Pointer Move
|
// Global Pointer Move
|
||||||
const handleGlobalPointerMove = (e: React.PointerEvent) => {
|
const handleGlobalPointerMove = (e: React.PointerEvent) => {
|
||||||
|
// 1. Yarn Link Dragging
|
||||||
|
if (yarnDrag) {
|
||||||
|
const zoom = settings.zoom || 1.0;
|
||||||
|
const deskX = e.clientX / zoom;
|
||||||
|
const deskY = e.clientY / zoom;
|
||||||
|
|
||||||
|
// Find any card under pointer (excluding origin)
|
||||||
|
const hovered = visibleCards.find(
|
||||||
|
(c) =>
|
||||||
|
c.id !== yarnDrag.fromCardId &&
|
||||||
|
deskX >= c.x - 15 &&
|
||||||
|
deskX <= c.x + c.width + 15 &&
|
||||||
|
deskY >= c.y - 15 &&
|
||||||
|
deskY <= c.y + c.height + 15
|
||||||
|
);
|
||||||
|
|
||||||
|
setYarnDrag((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
currentX: deskX,
|
||||||
|
currentY: deskY,
|
||||||
|
hoveredCardId: hovered ? hovered.id : null
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!draggingIdRef.current) return;
|
if (!draggingIdRef.current) return;
|
||||||
|
|
||||||
const zoom = settings.zoom || 1.0;
|
const zoom = settings.zoom || 1.0;
|
||||||
@@ -684,6 +756,30 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
// Global Pointer Up (Release with Momentum / Throw)
|
// Global Pointer Up (Release with Momentum / Throw)
|
||||||
const handleGlobalPointerUp = () => {
|
const handleGlobalPointerUp = () => {
|
||||||
|
// 1. Handle Yarn Drag Release
|
||||||
|
if (yarnDrag) {
|
||||||
|
if (yarnDrag.hoveredCardId && yarnDrag.hoveredCardId !== yarnDrag.fromCardId) {
|
||||||
|
const fromId = yarnDrag.fromCardId;
|
||||||
|
const toId = yarnDrag.hoveredCardId;
|
||||||
|
const exists = yarnLinks.some(
|
||||||
|
(l) => (l.fromId === fromId && l.toId === toId) || (l.fromId === toId && l.toId === fromId)
|
||||||
|
);
|
||||||
|
if (!exists) {
|
||||||
|
const newLink: YarnLink = {
|
||||||
|
id: `yarn-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`,
|
||||||
|
fromId,
|
||||||
|
toId
|
||||||
|
};
|
||||||
|
const nextLinks = [...yarnLinks, newLink];
|
||||||
|
setYarnLinks(nextLinks);
|
||||||
|
saveToServer(cards, propsList, strokes, settings, false, settings.deskTexture, undefined, nextLinks);
|
||||||
|
soundEngine.drop(settings.soundEnabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setYarnDrag(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!draggingIdRef.current) return;
|
if (!draggingIdRef.current) return;
|
||||||
|
|
||||||
const id = draggingIdRef.current;
|
const id = draggingIdRef.current;
|
||||||
@@ -733,6 +829,63 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Interactive Evidence Yarn handlers
|
||||||
|
const handleStartYarnDrag = (cardId: string, startX: number, startY: number) => {
|
||||||
|
if (settings.isLocked) return;
|
||||||
|
setYarnDrag({
|
||||||
|
fromCardId: cardId,
|
||||||
|
fromX: startX,
|
||||||
|
fromY: startY,
|
||||||
|
currentX: startX,
|
||||||
|
currentY: startY,
|
||||||
|
hoveredCardId: null
|
||||||
|
});
|
||||||
|
soundEngine.rustle(settings.soundEnabled, 0.6);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteYarnLink = (linkId: string) => {
|
||||||
|
const nextLinks = yarnLinks.filter((l) => l.id !== linkId);
|
||||||
|
setYarnLinks(nextLinks);
|
||||||
|
saveToServer(cards, propsList, strokes, settings, false, settings.deskTexture, undefined, nextLinks);
|
||||||
|
soundEngine.rustle(settings.soundEnabled, 0.8);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAutoLinkYarn = () => {
|
||||||
|
if (visibleCards.length < 2) return;
|
||||||
|
const newLinks: YarnLink[] = [];
|
||||||
|
const n = Math.min(visibleCards.length, 16);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
newLinks.push({
|
||||||
|
id: `yarn-ring-${i}-${Date.now()}`,
|
||||||
|
fromId: visibleCards[i].id,
|
||||||
|
toId: visibleCards[(i + 1) % n].id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (n >= 4) {
|
||||||
|
newLinks.push({
|
||||||
|
id: `yarn-chord-0-${Date.now()}`,
|
||||||
|
fromId: visibleCards[0].id,
|
||||||
|
toId: visibleCards[Math.floor(n / 2)].id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (n >= 6) {
|
||||||
|
newLinks.push({
|
||||||
|
id: `yarn-chord-1-${Date.now()}`,
|
||||||
|
fromId: visibleCards[1].id,
|
||||||
|
toId: visibleCards[Math.floor(n / 2) + 1].id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setYarnLinks(newLinks);
|
||||||
|
saveToServer(cards, propsList, strokes, settings, true, settings.deskTexture, undefined, newLinks);
|
||||||
|
soundEngine.shuffle(settings.soundEnabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearYarn = () => {
|
||||||
|
setYarnLinks([]);
|
||||||
|
saveToServer(cards, propsList, strokes, settings, true, settings.deskTexture, undefined, []);
|
||||||
|
soundEngine.rustle(settings.soundEnabled);
|
||||||
|
};
|
||||||
|
|
||||||
// Keyboard Shortcuts (S, G, M, P, N, Space)
|
// Keyboard Shortcuts (S, G, M, P, N, Space)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -1001,6 +1154,13 @@ export const App: React.FC = () => {
|
|||||||
<EvidenceStringsLayer
|
<EvidenceStringsLayer
|
||||||
cards={visibleCards}
|
cards={visibleCards}
|
||||||
enabled={settings.showEvidenceStrings}
|
enabled={settings.showEvidenceStrings}
|
||||||
|
yarnLinks={yarnLinks}
|
||||||
|
onDeleteLink={handleDeleteYarnLink}
|
||||||
|
activeDrag={yarnDrag}
|
||||||
|
onStartLinkDrag={handleStartYarnDrag}
|
||||||
|
onAutoLink={handleAutoLinkYarn}
|
||||||
|
onClearLinks={handleClearYarn}
|
||||||
|
maxZIndex={maxZIndex}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 4. Stationery Props Layer */}
|
{/* 4. Stationery Props Layer */}
|
||||||
|
|||||||
@@ -1,156 +1,341 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { CardItem } from '../types';
|
import { CardItem, YarnLink } from '../types';
|
||||||
|
import { Scissors, Sparkles, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
interface EvidenceStringsLayerProps {
|
interface EvidenceStringsLayerProps {
|
||||||
cards: CardItem[];
|
cards: CardItem[];
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
yarnLinks: YarnLink[];
|
||||||
|
onDeleteLink?: (linkId: string) => void;
|
||||||
|
activeDrag?: {
|
||||||
|
fromCardId: string;
|
||||||
|
fromX: number;
|
||||||
|
fromY: number;
|
||||||
|
currentX: number;
|
||||||
|
currentY: number;
|
||||||
|
hoveredCardId: string | null;
|
||||||
|
} | null;
|
||||||
|
onStartLinkDrag?: (cardId: string, startX: number, startY: number) => void;
|
||||||
|
onAutoLink?: () => void;
|
||||||
|
onClearLinks?: () => void;
|
||||||
|
maxZIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EvidenceStringsLayer: React.FC<EvidenceStringsLayerProps> = ({ cards, enabled }) => {
|
export const EvidenceStringsLayer: React.FC<EvidenceStringsLayerProps> = ({
|
||||||
if (!enabled || cards.length < 2) return null;
|
cards,
|
||||||
|
enabled,
|
||||||
|
yarnLinks,
|
||||||
|
onDeleteLink,
|
||||||
|
activeDrag,
|
||||||
|
onStartLinkDrag,
|
||||||
|
onAutoLink,
|
||||||
|
onClearLinks,
|
||||||
|
maxZIndex = 30
|
||||||
|
}) => {
|
||||||
|
const [hoveredLinkId, setHoveredLinkId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Build stable connection pairs so threads don't flicker randomly
|
if (!enabled) return null;
|
||||||
const connections = useMemo(() => {
|
|
||||||
const pairs: [number, number][] = [];
|
|
||||||
const n = Math.min(cards.length, 18);
|
|
||||||
|
|
||||||
// 1. Perimeter ring connections
|
// Build a lookup map of cards for O(1) coordinate resolution
|
||||||
for (let i = 0; i < n; i++) {
|
const cardMap = new Map<string, CardItem>();
|
||||||
pairs.push([i, (i + 1) % n]);
|
for (const card of cards) {
|
||||||
}
|
cardMap.set(card.id, card);
|
||||||
|
}
|
||||||
// 2. Cross-board conspiracy chords
|
|
||||||
if (n >= 4) {
|
|
||||||
pairs.push([0, Math.floor(n / 2)]);
|
|
||||||
}
|
|
||||||
if (n >= 6) {
|
|
||||||
pairs.push([1, Math.floor(n / 2) + 1]);
|
|
||||||
pairs.push([Math.floor(n / 3), (Math.floor(n / 3) + Math.floor(n / 2)) % n]);
|
|
||||||
}
|
|
||||||
if (n >= 8) {
|
|
||||||
pairs.push([2, n - 2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pairs;
|
|
||||||
}, [cards.length]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<>
|
||||||
className="absolute inset-0 w-full h-full pointer-events-none z-20 overflow-visible"
|
<svg
|
||||||
style={{ filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.45))' }}
|
className="absolute inset-0 w-full h-full pointer-events-none overflow-visible select-none"
|
||||||
>
|
style={{ zIndex: maxZIndex + 5, filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.45))' }}
|
||||||
<defs>
|
>
|
||||||
{/* Subtle red yarn texture pattern filter */}
|
<defs>
|
||||||
<filter id="yarn-fiber" x="0%" y="0%" width="100%" height="100%">
|
{/* Subtle red yarn texture pattern filter */}
|
||||||
<feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="2" result="noise" />
|
<filter id="yarn-fiber" x="0%" y="0%" width="100%" height="100%">
|
||||||
<feColorMatrix
|
<feTurbulence type="fractalNoise" baseFrequency="0.75" numOctaves="2" result="noise" />
|
||||||
type="matrix"
|
<feColorMatrix
|
||||||
values="1 0 0 0 0
|
type="matrix"
|
||||||
0 0.1 0 0 0
|
values="1 0 0 0 0
|
||||||
0 0 0.1 0 0
|
0 0.1 0 0 0
|
||||||
0 0 0 0.25 0"
|
0 0 0.1 0 0
|
||||||
/>
|
0 0 0 0.28 0"
|
||||||
<feComposite in="SourceGraphic" in2="noise" operator="over" />
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
{/* Render Yarn Threads */}
|
|
||||||
{connections.map(([idxA, idxB], i) => {
|
|
||||||
const cardA = cards[idxA];
|
|
||||||
const cardB = cards[idxB];
|
|
||||||
if (!cardA || !cardB) return null;
|
|
||||||
|
|
||||||
const ax = cardA.x + cardA.width / 2;
|
|
||||||
const ay = cardA.y + 12;
|
|
||||||
const bx = cardB.x + cardB.width / 2;
|
|
||||||
const by = cardB.y + 12;
|
|
||||||
|
|
||||||
const dist = Math.hypot(bx - ax, by - ay);
|
|
||||||
// Realistic catenary droop / sag
|
|
||||||
const sag = Math.min(45, Math.max(8, dist * 0.065));
|
|
||||||
const midX = (ax + bx) / 2;
|
|
||||||
const midY = (ay + by) / 2 + sag;
|
|
||||||
|
|
||||||
const pathData = `M ${ax.toFixed(1)} ${ay.toFixed(1)} Q ${midX.toFixed(1)} ${midY.toFixed(1)} ${bx.toFixed(1)} ${by.toFixed(1)}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={`thread-${cardA.id}-${cardB.id}-${i}`}>
|
|
||||||
{/* Thread shadow */}
|
|
||||||
<path
|
|
||||||
d={pathData}
|
|
||||||
fill="none"
|
|
||||||
stroke="rgba(0,0,0,0.3)"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
transform="translate(1, 3)"
|
|
||||||
/>
|
|
||||||
{/* Primary crimson red yarn */}
|
|
||||||
<path
|
|
||||||
d={pathData}
|
|
||||||
fill="none"
|
|
||||||
stroke="#DC2626"
|
|
||||||
strokeWidth="2.2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeDasharray={i % 3 === 0 ? 'none' : 'none'}
|
|
||||||
opacity="0.92"
|
|
||||||
/>
|
|
||||||
{/* Fiber highlight core */}
|
|
||||||
<path
|
|
||||||
d={pathData}
|
|
||||||
fill="none"
|
|
||||||
stroke="#F87171"
|
|
||||||
strokeWidth="0.8"
|
|
||||||
strokeLinecap="round"
|
|
||||||
opacity="0.6"
|
|
||||||
/>
|
/>
|
||||||
|
<feComposite in="SourceGraphic" in2="noise" operator="over" />
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
{/* Radial glow for connection targets */}
|
||||||
|
<radialGradient id="target-glow" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stopColor="#F59E0B" stopOpacity="0.8" />
|
||||||
|
<stop offset="60%" stopColor="#EF4444" stopOpacity="0.4" />
|
||||||
|
<stop offset="100%" stopColor="#DC2626" stopOpacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* 1. Render Existing Yarn Connections */}
|
||||||
|
{yarnLinks.map((link) => {
|
||||||
|
const cardA = cardMap.get(link.fromId);
|
||||||
|
const cardB = cardMap.get(link.toId);
|
||||||
|
if (!cardA || !cardB) return null;
|
||||||
|
|
||||||
|
const ax = cardA.x + cardA.width / 2;
|
||||||
|
const ay = cardA.y + 12;
|
||||||
|
const bx = cardB.x + cardB.width / 2;
|
||||||
|
const by = cardB.y + 12;
|
||||||
|
|
||||||
|
const dist = Math.hypot(bx - ax, by - ay);
|
||||||
|
// Realistic catenary droop / sag
|
||||||
|
const sag = Math.min(55, Math.max(10, dist * 0.075));
|
||||||
|
const midX = (ax + bx) / 2;
|
||||||
|
const midY = (ay + by) / 2 + sag;
|
||||||
|
|
||||||
|
const pathData = `M ${ax.toFixed(1)} ${ay.toFixed(1)} Q ${midX.toFixed(1)} ${midY.toFixed(1)} ${bx.toFixed(1)} ${by.toFixed(1)}`;
|
||||||
|
const isHovered = hoveredLinkId === link.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={link.id}
|
||||||
|
className="group"
|
||||||
|
onMouseEnter={() => setHoveredLinkId(link.id)}
|
||||||
|
onMouseLeave={() => setHoveredLinkId(null)}
|
||||||
|
>
|
||||||
|
{/* Invisible wide hit area for hovering & cutting */}
|
||||||
|
<path
|
||||||
|
d={pathData}
|
||||||
|
fill="none"
|
||||||
|
stroke="transparent"
|
||||||
|
strokeWidth="24"
|
||||||
|
className="pointer-events-auto cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteLink?.(link.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<title>Click or tap scissors to cut yarn connection</title>
|
||||||
|
</path>
|
||||||
|
|
||||||
|
{/* Thread Shadow */}
|
||||||
|
<path
|
||||||
|
d={pathData}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(0,0,0,0.35)"
|
||||||
|
strokeWidth="3.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform="translate(1.5, 3.5)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Primary crimson red yarn cord */}
|
||||||
|
<path
|
||||||
|
d={pathData}
|
||||||
|
fill="none"
|
||||||
|
stroke={isHovered ? '#EF4444' : '#DC2626'}
|
||||||
|
strokeWidth={isHovered ? '3.2' : '2.4'}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={isHovered ? '1.0' : '0.94'}
|
||||||
|
className="transition-all duration-150"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Fiber Highlight Core */}
|
||||||
|
<path
|
||||||
|
d={pathData}
|
||||||
|
fill="none"
|
||||||
|
stroke="#FCA5A5"
|
||||||
|
strokeWidth="0.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={isHovered ? '0.9' : '0.65'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Interactive Cut Badge on hover */}
|
||||||
|
{isHovered && (
|
||||||
|
<g
|
||||||
|
className="pointer-events-auto cursor-pointer"
|
||||||
|
transform={`translate(${midX}, ${midY})`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteLink?.(link.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<circle r="12" fill="#1C1A17" stroke="#DC2626" strokeWidth="1.5" />
|
||||||
|
<text
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
fill="#F87171"
|
||||||
|
fontSize="11"
|
||||||
|
fontWeight="bold"
|
||||||
|
y="0.5"
|
||||||
|
>
|
||||||
|
✂
|
||||||
|
</text>
|
||||||
|
<title>Cut yarn connection</title>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 2. Render Active Yarn Drag (Live string following pointer) */}
|
||||||
|
{activeDrag && (
|
||||||
|
<g>
|
||||||
|
{(() => {
|
||||||
|
const ax = activeDrag.fromX;
|
||||||
|
const ay = activeDrag.fromY;
|
||||||
|
const targetCard = activeDrag.hoveredCardId ? cardMap.get(activeDrag.hoveredCardId) : null;
|
||||||
|
const bx = targetCard ? targetCard.x + targetCard.width / 2 : activeDrag.currentX;
|
||||||
|
const by = targetCard ? targetCard.y + 12 : activeDrag.currentY;
|
||||||
|
|
||||||
|
const dist = Math.hypot(bx - ax, by - ay);
|
||||||
|
const sag = Math.min(45, Math.max(6, dist * 0.06));
|
||||||
|
const midX = (ax + bx) / 2;
|
||||||
|
const midY = (ay + by) / 2 + sag;
|
||||||
|
const pathData = `M ${ax.toFixed(1)} ${ay.toFixed(1)} Q ${midX.toFixed(1)} ${midY.toFixed(1)} ${bx.toFixed(1)} ${by.toFixed(1)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Shadow */}
|
||||||
|
<path
|
||||||
|
d={pathData}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(0,0,0,0.3)"
|
||||||
|
strokeWidth="3.0"
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform="translate(1.5, 3)"
|
||||||
|
/>
|
||||||
|
{/* Glowing live yarn cord */}
|
||||||
|
<path
|
||||||
|
d={pathData}
|
||||||
|
fill="none"
|
||||||
|
stroke="#EF4444"
|
||||||
|
strokeWidth="3.0"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray="6,3"
|
||||||
|
className="animate-pulse"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d={pathData}
|
||||||
|
fill="none"
|
||||||
|
stroke="#FECACA"
|
||||||
|
strokeWidth="1.0"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
{/* Floating lead pushpin on cursor */}
|
||||||
|
<circle cx={bx} cy={by} r="6" fill="#EF4444" stroke="#FFFFFF" strokeWidth="1.5" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</g>
|
</g>
|
||||||
);
|
)}
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Render Pushpins at each connection point on cards */}
|
{/* 3. Render Interactive Pushpins on Every Card */}
|
||||||
{cards.map((card) => {
|
{cards.map((card) => {
|
||||||
const px = card.x + card.width / 2;
|
const px = card.x + card.width / 2;
|
||||||
const py = card.y + 12;
|
const py = card.y + 12;
|
||||||
|
|
||||||
return (
|
const isOrigin = activeDrag?.fromCardId === card.id;
|
||||||
<g key={`pin-${card.id}`}>
|
const isTarget = activeDrag?.hoveredCardId === card.id;
|
||||||
{/* Pin shadow */}
|
|
||||||
<ellipse
|
return (
|
||||||
cx={px + 1}
|
<g
|
||||||
cy={py + 3.5}
|
key={`pin-${card.id}`}
|
||||||
rx="4.5"
|
className="pointer-events-auto cursor-crosshair group"
|
||||||
ry="2.5"
|
onPointerDown={(e) => {
|
||||||
fill="rgba(0,0,0,0.4)"
|
e.stopPropagation();
|
||||||
/>
|
onStartLinkDrag?.(card.id, px, py);
|
||||||
{/* Metallic brass bevel ring */}
|
}}
|
||||||
<circle
|
>
|
||||||
cx={px}
|
{/* Highlight Aura if ready to snap/connect */}
|
||||||
cy={py}
|
{isTarget && (
|
||||||
r="5.5"
|
<circle
|
||||||
fill="#D97706"
|
cx={px}
|
||||||
stroke="#78350F"
|
cy={py}
|
||||||
strokeWidth="0.8"
|
r="18"
|
||||||
/>
|
fill="url(#target-glow)"
|
||||||
{/* Red plastic pushpin cap */}
|
className="animate-ping"
|
||||||
<circle
|
opacity="0.75"
|
||||||
cx={px}
|
/>
|
||||||
cy={py}
|
)}
|
||||||
r="4.2"
|
|
||||||
fill="#DC2626"
|
{/* Pin Base Shadow */}
|
||||||
stroke="#991B1B"
|
<ellipse
|
||||||
strokeWidth="0.8"
|
cx={px + 1}
|
||||||
/>
|
cy={py + 3.5}
|
||||||
{/* Shiny specular highlight */}
|
rx="5"
|
||||||
<circle
|
ry="3"
|
||||||
cx={px - 1.2}
|
fill="rgba(0,0,0,0.45)"
|
||||||
cy={py - 1.2}
|
/>
|
||||||
r="1.4"
|
|
||||||
fill="#FECACA"
|
{/* Metallic brass bezel ring */}
|
||||||
opacity="0.9"
|
<circle
|
||||||
/>
|
cx={px}
|
||||||
</g>
|
cy={py}
|
||||||
);
|
r="6.5"
|
||||||
})}
|
fill="#D97706"
|
||||||
</svg>
|
stroke="#78350F"
|
||||||
|
strokeWidth="0.8"
|
||||||
|
className="transition-transform group-hover:scale-110"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Red plastic pushpin head */}
|
||||||
|
<circle
|
||||||
|
cx={px}
|
||||||
|
cy={py}
|
||||||
|
r={isTarget ? '6.0' : isOrigin ? '5.5' : '5.0'}
|
||||||
|
fill={isTarget ? '#EF4444' : '#DC2626'}
|
||||||
|
stroke={isTarget ? '#FDE047' : '#991B1B'}
|
||||||
|
strokeWidth={isTarget ? '1.5' : '0.8'}
|
||||||
|
className="transition-all"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Specular 3D highlight */}
|
||||||
|
<circle
|
||||||
|
cx={px - 1.5}
|
||||||
|
cy={py - 1.5}
|
||||||
|
r="1.6"
|
||||||
|
fill="#FECACA"
|
||||||
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hover tooltip */}
|
||||||
|
<title>Drag red yarn to another note to connect</title>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* 4. Evidence Web Control Banner (Floating hint when strings are active) */}
|
||||||
|
<div
|
||||||
|
className="fixed top-14 left-1/2 -translate-x-1/2 z-40 px-3 py-1 bg-[#1C1A17]/90 text-neutral-300 rounded-full shadow-xl border border-red-900/60 backdrop-blur-md flex items-center gap-2.5 text-[11px] font-mono pointer-events-auto select-none"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5 text-red-400 font-bold">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||||
|
<span>Evidence Strings ({yarnLinks.length})</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-neutral-500">|</span>
|
||||||
|
|
||||||
|
<span className="text-neutral-400 hidden sm:inline text-[10px]">
|
||||||
|
Drag from any pushpin to link notes · Click thread to cut
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{yarnLinks.length === 0 && onAutoLink && (
|
||||||
|
<button
|
||||||
|
onClick={onAutoLink}
|
||||||
|
className="px-2 py-0.5 rounded bg-red-950/80 hover:bg-red-900 text-red-200 border border-red-800/80 text-[10px] font-semibold flex items-center gap-1 transition"
|
||||||
|
title="Automatically link neighboring cards with evidence yarn"
|
||||||
|
>
|
||||||
|
<Sparkles size={11} className="text-amber-400" />
|
||||||
|
<span>Auto-Web</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{yarnLinks.length > 0 && onClearLinks && (
|
||||||
|
<button
|
||||||
|
onClick={onClearLinks}
|
||||||
|
className="px-2 py-0.5 rounded bg-neutral-800 hover:bg-red-950/80 text-neutral-400 hover:text-red-300 border border-neutral-700/80 text-[10px] flex items-center gap-1 transition"
|
||||||
|
title="Clear all yarn strings"
|
||||||
|
>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
<span>Clear</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -110,10 +110,18 @@ export interface DeskSettings {
|
|||||||
showEvidenceStrings: boolean; // evidence board red yarn connections
|
showEvidenceStrings: boolean; // evidence board red yarn connections
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface YarnLink {
|
||||||
|
id: string;
|
||||||
|
fromId: string;
|
||||||
|
toId: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BoardState {
|
export interface BoardState {
|
||||||
cards: CardItem[];
|
cards: CardItem[];
|
||||||
props?: DeskProp[];
|
props?: DeskProp[];
|
||||||
strokes?: InkStroke[];
|
strokes?: InkStroke[];
|
||||||
|
yarnLinks?: YarnLink[];
|
||||||
settings?: Partial<DeskSettings>;
|
settings?: Partial<DeskSettings>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -474,10 +474,21 @@ export function getDefaultBoardData(desk: DeskTexture, w = 1400, h = 900): Board
|
|||||||
isPinned: true, attachType: 'pin', fontFamily: 'font-mono'
|
isPinned: true, attachType: 'pin', fontFamily: 'font-mono'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
const defaultYarnLinks = [
|
||||||
|
{ id: 'ev-link-1', fromId: 'ev-photo-1', toId: 'ev-tag-1' },
|
||||||
|
{ id: 'ev-link-2', fromId: 'ev-tag-1', toId: 'ev-memo' },
|
||||||
|
{ id: 'ev-link-3', fromId: 'ev-memo', toId: 'ev-photo-2' },
|
||||||
|
{ id: 'ev-link-4', fromId: 'ev-photo-1', toId: 'ev-ticket' },
|
||||||
|
{ id: 'ev-link-5', fromId: 'ev-ticket', toId: 'ev-check' },
|
||||||
|
{ id: 'ev-link-6', fromId: 'ev-check', toId: 'ev-receipt' },
|
||||||
|
{ id: 'ev-link-7', fromId: 'ev-receipt', toId: 'ev-graph' },
|
||||||
|
{ id: 'ev-link-8', fromId: 'ev-memo', toId: 'ev-graph' }
|
||||||
|
];
|
||||||
return {
|
return {
|
||||||
cards,
|
cards,
|
||||||
props: INITIAL_PROPS,
|
props: INITIAL_PROPS,
|
||||||
strokes: [],
|
strokes: [],
|
||||||
|
yarnLinks: defaultYarnLinks,
|
||||||
settings: { deskTexture: 'evidence-board', lightAngle: 215, showEvidenceStrings: true }
|
settings: { deskTexture: 'evidence-board', lightAngle: 215, showEvidenceStrings: true }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user