Implement interactive pushpins and drag-to-connect red yarn webbing for Evidence Board

This commit is contained in:
airent106 committed 2026-10-13 12:00:56 -06:00
1 parent 760ba2ff53
commit ac0c3bdf79
5 files changed
+515 -148

No files matched your search

+4 -1
View File
@@ -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
View File
@@ -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 */}
+325 -140
View File
@@ -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>
</>
); );
}; };
+8
View File
@@ -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>;
} }
+11
View File
@@ -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 }
}; };
} }