import { useState, useRef, useCallback, useMemo } from "react";
// ── Design tokens ──────────────────────────────────
const T = {
bg: "#0A0A0C",
panel: "#141418",
surface: "#1C1C22",
border: "#2A2A32",
amber: "#E8A030",
amberDim: "#7A5218",
blue: "#3A7BA8",
blueDim: "#1E4060",
green: "#3AB87A",
red: "#C84040",
textPrimary: "#F0EDE6",
textSec: "#8888A0",
textDim: "#444458",
};
// ── Split type metadata ────────────────────────────
const SPLIT_TYPES = {
y: { label: "Y", desc: "Split evenly", icon: "⬡" },
ab: { label: "A/B", desc: "Route all to A or B", icon: "⇄" },
cascade: { label: "Cascade", desc: "A feeds into B", icon: "⇢" },
a_to_b: { label: "A→B", desc: "Serial path extension", icon: "↷" },
};
// ── Type-to-color mapping ───────────────────────────
const TYPE_COLORS = {
od: T.amber, overdrive: T.amber, drive: T.amber,
dist: T.red, distortion: T.red,
fuzz: "#D060E0",
mod: T.blue, chorus: T.blue, flange: T.blue, phaser: T.blue,
tremolo: T.blue, vibrato: T.blue, rotary: T.blue,
delay: "#60A0E0", echo: "#60A0E0", reverb: "#60C0D0",
pitch: "#A060E0", comp: T.green, compressor: T.green,
gate: T.green, filter: "#D0A060", wah: "#D0A060",
eq: "#80A0B0", ir: T.green, cab: T.green, nam: "#E0A040",
volume: T.textSec, boost: T.amber,
split: "#B080C0", merge: "#80B0C0",
};
// ── Type-to-SVG-icon mapping (from pipedal icon set) ──
const TYPE_ICON_MAP = {
od: "fx_distortion", overdrive: "fx_distortion", drive: "fx_distortion",
dist: "fx_distortion", distortion: "fx_distortion", gain: "fx_distortion",
fuzz: "fx_distortion",
delay: "fx_delay", echo: "fx_delay",
reverb: "fx_reverb",
chorus: "fx_chorus", flange: "fx_flanger", flanger: "fx_flanger",
phaser: "fx_phaser", tremolo: "fx_modulator", vibrato: "fx_modulator",
rotary: "fx_modulator", modulator: "fx_modulator",
pitch: "fx_pitch", harmonizer: "fx_pitch", shifter: "fx_pitch",
comp: "fx_compressor", compressor: "fx_compressor",
limiter: "fx_limiter",
gate: "fx_gate", noise_gate: "fx_gate",
filter: "fx_filter", wah: "fx_filter",
eq: "fx_eq", graphic_eq: "fx_eq",
parametric_eq: "fx_parametric_eq", multiband_eq: "fx_multiband_eq",
ir: "fx_spatial", cab: "fx_spatial", cabinet: "fx_spatial",
nam: "fx_simulator", capture: "fx_simulator", amp: "fx_amplifier",
amplifier: "fx_amplifier",
volume: "fx_constant", level: "fx_constant", boost: "fx_constant",
split: "fx_split_a", merge: "fx_mixer",
tuner: "fx_analyzer",
mixer: "fx_mixer", utility: "fx_utility",
plugin: "fx_plugin",
instrument: "fx_instrument",
reamp: "fx_converter",
looper: "fx_oscillator",
};
const IMG_PATH = (import.meta.env.BASE_URL || '/ui/') + 'img/';
// ── SVG Block Icon component ─────────────────────────
function BlockIcon({ type, size = 14, style = {} }) {
const key = (type || '').toLowerCase().trim();
let icon = 'fx_plugin';
let found = false;
for (const [k, v] of Object.entries(TYPE_ICON_MAP)) {
if (key === k || key.startsWith(k) || key.includes(k)) {
icon = v;
found = true;
break;
}
}
if (!found && key) {
// Try direct match by replacing spaces/special chars
const slug = key.replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
if (TYPE_ICON_MAP[slug]) icon = TYPE_ICON_MAP[slug];
}
return (
{ e.target.style.display = 'none'; }}
/>
);
}
function getBlockColor(type) {
const key = (type || "").toLowerCase().trim();
for (const [k, v] of Object.entries(TYPE_COLORS)) {
if (key === k || key.startsWith(k) || key.includes(k)) return v;
}
return T.amber;
}
// ── Chain Arrow SVG ──────────────────────────────
function ChainArrow({ color = T.border, narrow = false }) {
const w = narrow ? 14 : 18;
return (
);
}
// ── IN / OUT Terminator ─────────────────────────
function Terminator({ label, isLeft }) {
return (
{!isLeft &&
}
{label}
{isLeft &&
}
);
}
// ── Vertical Connection Bracket ───────────────────
function VertBracket({ height, direction }) {
// direction: 'down' (split) or 'up' (merge)
const dh = Math.max(height, 18);
return (
);
}
// ── Split Block Tile ─────────────────────────────
function SplitBlockTile({ block, selected, onSelect, onToggle }) {
const splitParams = block.params || {};
const splitType = splitParams.splitType || "y";
const st = SPLIT_TYPES[splitType] || SPLIT_TYPES.y;
const enabled = !block.bypassed;
const color = getBlockColor("split");
return (
onSelect(block.id)}
style={{
minWidth: 130, width: 130, height: 76, borderRadius: 8,
background: `linear-gradient(135deg, ${T.panel}, #1A1520)`,
border: `2px solid ${selected ? T.amber : color}60`,
opacity: enabled ? 1 : 0.35, cursor: "pointer",
display: "flex", flexDirection: "column", position: "relative",
overflow: "hidden", transition: "all .15s ease", flexShrink: 0,
boxShadow: selected ? `0 0 16px ${T.amber}44, inset 0 0 0 1px ${T.amber}22` : "none",
transform: selected ? "scale(1.05)" : "scale(1)",
}}
>
{/* Gradient accent */}
{/* BYPASS LED */}
{ e.stopPropagation(); onToggle(block.id); }}
style={{
position: "absolute", top: 6, right: 6, width: 10, height: 10,
borderRadius: "50%", background: enabled ? T.green : T.textDim,
boxShadow: enabled ? `0 0 8px ${T.green}` : "none",
transition: "all .15s", cursor: "pointer", zIndex: 2,
border: `1px solid ${enabled ? `${T.green}88` : T.border}`,
}} />
{/* Content */}
SPLIT {st.label}
{st.desc}
{/* Split path indicators */}
A {splitType === "ab" && (splitParams.activePath === "B" ? "—" : "✓")}
B {splitType === "ab" && splitParams.activePath === "B" ? "✓" : "—"}
);
}
// ── Merge Block Tile ──────────────────────────────
function MergeBlockTile({ block, selected, onSelect, onToggle }) {
const mergeParams = block.params || {};
const enabled = !block.bypassed;
return (
onSelect(block.id)}
style={{
minWidth: 130, width: 130, height: 76, borderRadius: 8,
background: `linear-gradient(135deg, #15201A, ${T.panel})`,
border: `2px solid ${selected ? T.amber : "#80B0C0"}60`,
opacity: enabled ? 1 : 0.35, cursor: "pointer",
display: "flex", flexDirection: "column", position: "relative",
overflow: "hidden", transition: "all .15s ease", flexShrink: 0,
boxShadow: selected ? `0 0 16px ${T.amber}44, inset 0 0 0 1px ${T.amber}22` : "none",
transform: selected ? "scale(1.05)" : "scale(1)",
}}
>
{ e.stopPropagation(); onToggle(block.id); }}
style={{
position: "absolute", top: 6, right: 6, width: 10, height: 10,
borderRadius: "50%", background: enabled ? T.green : T.textDim,
boxShadow: enabled ? `0 0 8px ${T.green}` : "none",
transition: "all .15s", cursor: "pointer", zIndex: 2,
border: `1px solid ${enabled ? `${T.green}88` : T.border}`,
}} />
MERGE
Blend {mergeParams.blend ?? 100}%
Lv {mergeParams.levelA ?? 100}
Lv {mergeParams.levelB ?? 100}
);
}
// ── Regular Block Tile ────────────────────────────
function BlockTile({ block, selected, onSelect, onToggle, pathLabel }) {
const color = getBlockColor(block.type);
const enabled = !block.bypassed;
return (
onSelect(block.id)}
style={{
minWidth: 120, width: 120, height: 76, borderRadius: 8,
background: pathLabel === "B" ? "#1A1820" : T.panel,
border: `2px solid ${selected ? T.amber : T.border}`,
opacity: enabled ? 1 : 0.35, cursor: "pointer",
display: "flex", flexDirection: "column", position: "relative",
overflow: "hidden", transition: "all .15s ease", flexShrink: 0,
boxShadow: selected
? `0 0 16px ${T.amber}44, inset 0 0 0 1px ${T.amber}22`
: pathLabel === "B" ? "inset 0 0 0 1px rgba(128,176,192,0.15)" : "none",
transform: selected ? "scale(1.05)" : "scale(1)",
}}
>
{/* Color accent strip */}
{/* Drag handle */}
⋮⋮
{/* BYPASS LED */}
{ e.stopPropagation(); onToggle(block.id); }}
style={{
position: "absolute", top: 6, right: 6, width: 10, height: 10,
borderRadius: "50%", background: enabled ? T.green : T.textDim,
boxShadow: enabled ? `0 0 8px ${T.green}` : "none",
transition: "all .15s", cursor: "pointer", zIndex: 2,
border: `1px solid ${enabled ? `${T.green}88` : T.border}`,
}}
title={enabled ? "Active — tap to bypass" : "Bypassed — tap to enable"}
/>
{/* Scribble strip */}
{pathLabel && (
{pathLabel === "A" ? "A" : "B"}
)}
{block.name || block.type || "FX"}
{(block.type || "fx").replace(/_/g, " ").toUpperCase()}
);
}
// ── Empty state placeholder block ──────────────────
function EmptyBlock({ label }) {
return (
{label || "+"}
);
}
// ── Chain model builder ──────────────────────────
function buildChainModel(blocks) {
const splitIdx = blocks.findIndex(b => b.type === "split");
const mergeIdx = blocks.findIndex(b => b.type === "merge");
// No split/merge — simple serial chain
if (splitIdx === -1 || mergeIdx === -1) {
return { type: "serial", blocks };
}
// Split/merge present — parallel routing
const beforeSplit = blocks.slice(0, splitIdx);
const splitBlock = blocks[splitIdx];
const parallelBlocks = blocks.slice(splitIdx + 1, mergeIdx);
const mergeBlock = blocks[mergeIdx];
const afterMerge = blocks.slice(mergeIdx + 1);
const pathABlocks = parallelBlocks.filter(b => !b.path || b.path === "A");
const pathBBlocks = parallelBlocks.filter(b => b.path === "B");
return {
type: "parallel",
beforeSplit,
splitBlock,
pathABlocks,
pathBBlocks,
mergeBlock,
afterMerge,
parallelBlockCount: Math.max(pathABlocks.length, pathBBlocks.length),
};
}
// ── Block Chain (exported) ─────────────────────────
export default function BlockChain({
blocks, selectedBlockId, onSelectBlock, onToggleBlock,
onReorder, onAddBlock, onRemoveBlock,
}) {
const scrollRef = useRef(null);
const scrollBRef = useRef(null);
const [dragIdx, setDragIdx] = useState(null);
const [scrollPos, setScrollPos] = useState(0);
const model = useMemo(() => buildChainModel(blocks), [blocks]);
// ── HTML5 drag-to-reorder ──
const handleDragStart = useCallback((e, idx) => {
setDragIdx(idx);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(idx));
}, []);
const handleDragOver = useCallback((e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}, []);
const handleDrop = useCallback((e, dropIdx) => {
e.preventDefault();
if (dragIdx !== null && dragIdx !== dropIdx && onReorder) {
onReorder(dragIdx, dropIdx);
}
setDragIdx(null);
}, [dragIdx, onReorder]);
const handleDragEnd = useCallback(() => {
setDragIdx(null);
}, []);
// ── Auto-scroll selected block into view ──
const scrollToSelected = useCallback(() => {
if (!scrollRef.current || !selectedBlockId) return;
const chainBlocks = model.type === "serial" ? blocks : [
...model.beforeSplit,
model.splitBlock,
...model.pathABlocks,
model.mergeBlock,
...model.afterMerge,
].filter(Boolean);
const idx = chainBlocks.findIndex(b => b.id === selectedBlockId);
if (idx < 0) return;
const child = scrollRef.current.children[idx + (model.type === "parallel" ? 1 : 1)];
if (child) child.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
}, [selectedBlockId, model, blocks]);
const prevSelected = useRef(selectedBlockId);
if (selectedBlockId !== prevSelected.current) {
prevSelected.current = selectedBlockId;
setTimeout(scrollToSelected, 50);
}
// ── Sync scroll between rows in parallel mode ──
const handleScrollMain = useCallback((e) => {
setScrollPos(e.target.scrollLeft);
if (scrollBRef.current) {
scrollBRef.current.scrollLeft = e.target.scrollLeft;
}
}, []);
const selectedBlock = blocks.find(b => b.id === selectedBlockId);
// ── Serial Chain Renderer ──
const renderSerialChain = () => (
{blocks.map((block, idx) => (
handleDragStart(e, idx)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, idx)}
onDragEnd={handleDragEnd}
style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}
>
))}
{blocks.length === 0 && (
No blocks in chain
)}
);
// ── Parallel Chain Renderer ──
const renderParallelChain = () => {
const {
beforeSplit, splitBlock, pathABlocks, pathBBlocks,
mergeBlock, afterMerge, parallelBlockCount,
} = model;
// Build a paired layout for the parallel section
const pairs = [];
const maxLen = Math.max(pathABlocks.length, pathBBlocks.length);
for (let i = 0; i < maxLen; i++) {
pairs.push({
a: pathABlocks[i] || null,
b: pathBBlocks[i] || null,
});
}
return (
{/* Row 1: Main path + Path A */}
{/* Pre-split blocks */}
{beforeSplit.map((block, idx) => (
))}
{/* Split block */}
{/* Path A blocks (parallel section) */}
{pathABlocks.map((block, idx) => (
))}
{/* Merge block */}
{pathABlocks.length > 0 && (
)}
{/* Post-merge blocks */}
{afterMerge.map((block, idx) => (
))}
{/* Vertical connector zone */}
{/* Spacer area before split */}
{/* IN terminator + pre-split blocks + split-blocks width as spacer */}
{/* We use absolute positioning relative to the row width */}
{/* Row 2: Path B */}
{/* Align spacer for IN + before split + split block */}
{/* Estimate: IN label (40px) + arrows per pre block + block widths + split block + arrows */}
{beforeSplit.length > 0 && (
{beforeSplit.map((b, i) => (
──
))}
)}
{/* Show bracket connected from split block */}
{/* Downward arrow indicator */}
{/* Path B blocks */}
{pathBBlocks.length === 0 && (
PATH B
Add blocks to Path B
)}
{pathBBlocks.map((block, idx) => (
))}
{/* Upward arrow to merge */}
{pathBBlocks.length > 0 && (
)}
{/* Gap spacer for merge + after-merge + OUT terminator - we add a flexible spacer */}
{/* Path B label at end */}
{pathBBlocks.length > 0 && (
Path B
)}
{/* Path labels */}
);
};
// ── Render ──
return (
{model.type === "parallel" ? renderParallelChain() : renderSerialChain()}
{/* ── Add/Remove bar ── */}
{selectedBlockId && (
)}
);
}