Files
pi-multifx-pedal-ui/src/BlockChain.jsx
T
shawn 62c373b989 feat: pipedal SVG icons in block tiles + nav bar
- Replace emoji getBlockIcon() with <BlockIcon> component rendering SVG icons
- 38 fx_*.svg icons from pipedal mapped to block types (distortion, delay, reverb, mod, etc.)
- Split/merge tiles now use fx_split_a.svg / fx_mixer.svg
- Status bar: ic_bank.svg, ic_presets.svg, fx_analyzer.svg, ic_drawer_2.svg
- Extracted reusable CSS design system to index.css (badge, snap-grid, slide-panel, etc.)
- SnapshotPanel and component reference extracted to docs/
- 270KB JS build, SVG icons served from /ui/img/
2026-06-12 19:29:50 -04:00

813 lines
30 KiB
React

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 (
<img
src={`${IMG_PATH}${icon}.svg`}
alt={type || 'fx'}
style={{
width: size, height: size, flexShrink: 0,
filter: 'brightness(1.2) contrast(1.1)',
objectFit: 'contain',
...style,
}}
onError={(e) => { 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 (
<svg width={w} height="24" viewBox={`0 0 ${w} 24`} fill="none" style={{ flexShrink: 0 }}>
<line x1="0" y1="12" x2={w - 6} y2="12" stroke={color} strokeWidth="2" strokeLinecap="round" />
<polyline points={`${w-10},7 ${w-4},12 ${w-10},17`} fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
// ── IN / OUT Terminator ─────────────────────────
function Terminator({ label, isLeft }) {
return (
<div style={{
display: "flex", alignItems: "center", gap: 6, flexShrink: 0,
flexDirection: isLeft ? "row" : "row-reverse",
}}>
{!isLeft && <ChainArrow />}
<div style={{
padding: "7px 12px", borderRadius: 6, fontSize: 10, fontWeight: 700,
letterSpacing: ".12em", background: T.blueDim, color: T.blue,
border: `1px solid ${T.blue}60`, textTransform: "uppercase", whiteSpace: "nowrap",
}}>
{label}
</div>
{isLeft && <ChainArrow />}
</div>
);
}
// ── Vertical Connection Bracket ───────────────────
function VertBracket({ height, direction }) {
// direction: 'down' (split) or 'up' (merge)
const dh = Math.max(height, 18);
return (
<svg width="20" height={dh} viewBox={`0 0 20 ${dh}`} fill="none" style={{ flexShrink: 0 }}>
<line x1="10" y1="0" x2="10" y2={dh} stroke={T.border} strokeWidth="1.5" />
<line x1="10" y1={direction === "down" ? 0 : dh - 6}
x2={direction === "down" ? 16 : 16}
y2={direction === "down" ? 0 : dh - 6}
stroke={T.border} strokeWidth="1.5" />
<polyline points={direction === "down"
? "14,4 18,0 14,-4"
: `14,${dh-2} 18,${dh-6} 14,${dh-10}`}
fill="none" stroke={T.border} strokeWidth="1.5" />
</svg>
);
}
// ── 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 (
<div
draggable
onClick={() => 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 */}
<div style={{
position: "absolute", top: 0, left: 0, right: 0, height: 3,
background: `linear-gradient(90deg, #B080C0, #80B0C0)`,
}} />
{/* BYPASS LED */}
<div onClick={(e) => { 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 */}
<div style={{ flex: 1, padding: "14px 10px 6px", display: "flex", flexDirection: "column", justifyContent: "center" }}>
<div style={{
fontSize: 12, fontWeight: 700, color: color,
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
lineHeight: 1.3, display: "flex", alignItems: "center", gap: 6,
}}>
<BlockIcon type="split" size={16} />
<span>SPLIT {st.label}</span>
</div>
<div style={{ fontSize: 9, color: T.textSec, marginTop: 2 }}>
{st.desc}
</div>
</div>
{/* Split path indicators */}
<div style={{
display: "flex", borderTop: `1px solid ${T.border}44`,
}}>
<div style={{
flex: 1, fontSize: 8, fontWeight: 700, textAlign: "center",
padding: "1px 0", color: "#B080C0",
letterSpacing: ".1em", borderRight: `1px solid ${T.border}44`,
}}>
A {splitType === "ab" && (splitParams.activePath === "B" ? "—" : "✓")}
</div>
<div style={{
flex: 1, fontSize: 8, fontWeight: 700, textAlign: "center",
padding: "1px 0", color: "#80B0C0",
letterSpacing: ".1em",
}}>
B {splitType === "ab" && splitParams.activePath === "B" ? "✓" : "—"}
</div>
</div>
</div>
);
}
// ── Merge Block Tile ──────────────────────────────
function MergeBlockTile({ block, selected, onSelect, onToggle }) {
const mergeParams = block.params || {};
const enabled = !block.bypassed;
return (
<div
draggable
onClick={() => 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)",
}}
>
<div style={{
position: "absolute", top: 0, left: 0, right: 0, height: 3,
background: `linear-gradient(90deg, #80B0C0, #60A080)`,
}} />
<div onClick={(e) => { 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}`,
}} />
<div style={{ flex: 1, padding: "14px 10px 6px", display: "flex", flexDirection: "column", justifyContent: "center" }}>
<div style={{
fontSize: 12, fontWeight: 700, color: "#80B0C0",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
lineHeight: 1.3, display: "flex", alignItems: "center", gap: 6,
}}>
<BlockIcon type="merge" size={16} />
<span>MERGE</span>
</div>
<div style={{ fontSize: 9, color: T.textSec, marginTop: 2 }}>
Blend {mergeParams.blend ?? 100}%
</div>
</div>
<div style={{
display: "flex", borderTop: `1px solid ${T.border}44`,
}}>
<div style={{
flex: 1, fontSize: 8, fontWeight: 700, textAlign: "center",
padding: "1px 0", color: "#B080C0",
letterSpacing: ".1em", borderRight: `1px solid ${T.border}44`,
}}>
Lv {mergeParams.levelA ?? 100}
</div>
<div style={{
flex: 1, fontSize: 8, fontWeight: 700, textAlign: "center",
padding: "1px 0", color: "#80B0C0",
letterSpacing: ".1em",
}}>
Lv {mergeParams.levelB ?? 100}
</div>
</div>
</div>
);
}
// ── Regular Block Tile ────────────────────────────
function BlockTile({ block, selected, onSelect, onToggle, pathLabel }) {
const color = getBlockColor(block.type);
const enabled = !block.bypassed;
return (
<div
draggable
onClick={() => 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 */}
<div style={{
position: "absolute", top: 0, left: 0, right: 0, height: 3,
background: color,
boxShadow: enabled ? `0 0 6px ${color}` : "none",
}} />
{/* Drag handle */}
<div style={{
position: "absolute", top: 5, left: 5, fontSize: 9, color: T.textDim,
letterSpacing: 1.5, lineHeight: 1, cursor: "grab", opacity: 0.5,
}}>
</div>
{/* BYPASS LED */}
<div onClick={(e) => { 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 */}
<div style={{
flex: 1, padding: "14px 10px 6px",
display: "flex", flexDirection: "column", justifyContent: "center",
}}>
{pathLabel && (
<div style={{
position: "absolute", top: 5, right: 20, fontSize: 7,
fontWeight: 700, color: pathLabel === "A" ? "#B080C0" : "#80B0C0",
letterSpacing: ".08em", background: `${T.bg}88`,
padding: "0 4px", borderRadius: 2, lineHeight: 1.4,
}}>
{pathLabel === "A" ? "A" : "B"}
</div>
)}
<div style={{
fontSize: 12, fontWeight: 600, color: T.textPrimary,
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
lineHeight: 1.3, paddingRight: 16,
}}>
{block.name || block.type || "FX"}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 5, marginTop: 3 }}>
<BlockIcon type={block.type} size={14} />
<span style={{
fontSize: 9, fontWeight: 700, color, letterSpacing: ".08em",
textTransform: "uppercase", background: `${color}18`,
padding: "1px 5px", borderRadius: 3,
}}>
{(block.type || "fx").replace(/_/g, " ").toUpperCase()}
</span>
</div>
</div>
</div>
);
}
// ── Empty state placeholder block ──────────────────
function EmptyBlock({ label }) {
return (
<div style={{
minWidth: 120, width: 120, height: 76, borderRadius: 8,
border: `1px dashed ${T.border}44`, flexShrink: 0,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 9, color: T.textDim, letterSpacing: ".06em",
}}>
{label || "+"}
</div>
);
}
// ── 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 = () => (
<div
ref={scrollRef}
onScroll={handleScrollMain}
style={{
display: "flex", alignItems: "center", gap: 4,
padding: "10px 12px", overflowX: "auto", overflowY: "hidden",
minHeight: 96, background: T.bg,
borderBottom: `1px solid ${T.border}`,
scrollBehavior: "smooth", WebkitOverflowScrolling: "touch",
}}
>
<Terminator label="IN" isLeft />
{blocks.map((block, idx) => (
<div key={block.id} draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, idx)}
onDragEnd={handleDragEnd}
style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}
>
<BlockTile
block={block}
selected={block.id === selectedBlockId}
index={idx}
onSelect={onSelectBlock}
onToggle={onToggleBlock}
/>
<ChainArrow />
</div>
))}
{blocks.length === 0 && (
<div style={{
flexShrink: 0, padding: "22px 20px", color: T.textDim, fontSize: 12,
textAlign: "center", border: `1px dashed ${T.border}`, borderRadius: 8, minWidth: 140,
}}>
No blocks in chain
</div>
)}
<Terminator label="OUT" isLeft={false} />
</div>
);
// ── 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 (
<div style={{ background: T.bg, borderBottom: `1px solid ${T.border}`, position: "relative" }}>
{/* Row 1: Main path + Path A */}
<div
ref={scrollRef}
onScroll={handleScrollMain}
style={{
display: "flex", alignItems: "center", gap: 4,
padding: "8px 12px 0", overflowX: "auto", overflowY: "hidden",
minHeight: 76, scrollBehavior: "smooth",
WebkitOverflowScrolling: "touch",
}}
>
<Terminator label="IN" isLeft />
{/* Pre-split blocks */}
{beforeSplit.map((block, idx) => (
<div key={block.id} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
<BlockTile
block={block}
selected={block.id === selectedBlockId}
index={idx}
onSelect={onSelectBlock}
onToggle={onToggleBlock}
/>
<ChainArrow />
</div>
))}
{/* Split block */}
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
<SplitBlockTile
block={splitBlock}
selected={splitBlock.id === selectedBlockId}
onSelect={onSelectBlock}
onToggle={onToggleBlock}
/>
<ChainArrow />
</div>
{/* Path A blocks (parallel section) */}
{pathABlocks.map((block, idx) => (
<div key={block.id} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
<BlockTile
block={block}
selected={block.id === selectedBlockId}
index={idx}
onSelect={onSelectBlock}
onToggle={onToggleBlock}
pathLabel="A"
/>
<ChainArrow />
</div>
))}
{/* Merge block */}
{pathABlocks.length > 0 && (
<ChainArrow />
)}
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
<MergeBlockTile
block={mergeBlock}
selected={mergeBlock.id === selectedBlockId}
onSelect={onSelectBlock}
onToggle={onToggleBlock}
/>
<ChainArrow />
</div>
{/* Post-merge blocks */}
{afterMerge.map((block, idx) => (
<div key={block.id} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
<BlockTile
block={block}
selected={block.id === selectedBlockId}
index={idx}
onSelect={onSelectBlock}
onToggle={onToggleBlock}
/>
<ChainArrow />
</div>
))}
<Terminator label="OUT" isLeft={false} />
</div>
{/* Vertical connector zone */}
<div style={{
display: "flex", alignItems: "stretch",
padding: "0 12px", position: "relative",
}}>
{/* Spacer area before split */}
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0, minHeight: 24 }}>
{/* IN terminator + pre-split blocks + split-blocks width as spacer */}
<div style={{ width: "calc(100%)", position: "relative" }}>
{/* We use absolute positioning relative to the row width */}
</div>
</div>
</div>
{/* Row 2: Path B */}
<div
ref={scrollBRef}
style={{
display: "flex", alignItems: "center", gap: 4,
padding: "2px 12px 8px", overflowX: "auto", overflowY: "hidden",
minHeight: 76, scrollBehavior: "smooth",
WebkitOverflowScrolling: "touch",
borderTop: `1px solid ${T.border}33`,
}}
>
{/* Align spacer for IN + before split + split block */}
<div style={{
display: "flex", alignItems: "center", gap: 4, flexShrink: 0,
paddingRight: 4, opacity: 0.3, pointerEvents: "none",
}}>
{/* Estimate: IN label (40px) + arrows per pre block + block widths + split block + arrows */}
{beforeSplit.length > 0 && (
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
{beforeSplit.map((b, i) => (
<div key={b.id} style={{
minWidth: 124, height: 20, fontSize: 8, color: T.textDim,
display: "flex", alignItems: "center", gap: 4,
}}>
<span></span>
</div>
))}
</div>
)}
{/* Show bracket connected from split block */}
</div>
{/* Downward arrow indicator */}
<div style={{
display: "flex", alignItems: "center", gap: 4, flexShrink: 0, opacity: 0.6,
}}>
<svg width="22" height="24" viewBox="0 0 22 24" fill="none" style={{ flexShrink: 0 }}>
<line x1="8" y1="0" x2="8" y2="18" stroke={T.blue} strokeWidth="1.5" strokeDasharray="3,2" />
<line x1="2" y1="18" x2="18" y2="18" stroke={T.blue} strokeWidth="1.5" />
<polyline points="14,14 18,18 14,22" fill="none" stroke={T.blue} strokeWidth="1.5" />
</svg>
</div>
{/* Path B blocks */}
{pathBBlocks.length === 0 && (
<div style={{
flexShrink: 0, padding: "16px 16px", color: T.textDim, fontSize: 10,
border: `1px dashed ${T.border}44`, borderRadius: 6,
display: "flex", alignItems: "center", gap: 8,
}}>
<span style={{ color: "#80B0C0", fontSize: 8, fontWeight: 700, letterSpacing: ".1em" }}>PATH B</span>
<span>Add blocks to Path B</span>
</div>
)}
{pathBBlocks.map((block, idx) => (
<div key={block.id} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
<BlockTile
block={block}
selected={block.id === selectedBlockId}
index={idx + beforeSplit.length + 1}
onSelect={onSelectBlock}
onToggle={onToggleBlock}
pathLabel="B"
/>
<ChainArrow narrow />
</div>
))}
{/* Upward arrow to merge */}
{pathBBlocks.length > 0 && (
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0, opacity: 0.6 }}>
<svg width="22" height="24" viewBox="0 0 22 24" fill="none" style={{ flexShrink: 0 }}>
<line x1="14" y1="24" x2="14" y2="6" stroke={T.blue} strokeWidth="1.5" strokeDasharray="3,2" />
<line x1="4" y1="6" x2="20" y2="6" stroke={T.blue} strokeWidth="1.5" />
<polyline points="8,10 4,6 8,2" fill="none" stroke={T.blue} strokeWidth="1.5" />
</svg>
</div>
)}
{/* Gap spacer for merge + after-merge + OUT terminator - we add a flexible spacer */}
<div style={{ flex: 1, minWidth: 20 }} />
{/* Path B label at end */}
{pathBBlocks.length > 0 && (
<div style={{
flexShrink: 0, fontSize: 7, fontWeight: 700, color: "#80B0C0",
letterSpacing: ".12em", textTransform: "uppercase",
padding: "0 8px", opacity: 0.5,
}}>
Path B
</div>
)}
</div>
{/* Path labels */}
<div style={{
position: "absolute", left: 0, top: 0, bottom: 0,
width: 20, display: "flex", flexDirection: "column",
pointerEvents: "none", opacity: 0.4,
}}>
<div style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 8, fontWeight: 700, color: "#B080C0", writingMode: "vertical-rl",
letterSpacing: ".2em",
}}>
A
</div>
<div style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 8, fontWeight: 700, color: "#80B0C0", writingMode: "vertical-rl",
letterSpacing: ".2em",
}}>
B
</div>
</div>
</div>
);
};
// ── Render ──
return (
<div style={{ display: "flex", flexDirection: "column" }}>
{model.type === "parallel" ? renderParallelChain() : renderSerialChain()}
{/* ── Add/Remove bar ── */}
<div style={{
display: "flex", gap: 8, padding: "8px 12px",
borderBottom: `1px solid ${T.border}`, background: T.panel,
}}>
<button onClick={onAddBlock} style={{
flex: 1, padding: "10px", borderRadius: 7, background: T.surface,
border: `1px dashed ${T.border}`, color: T.textSec, fontSize: 12,
fontWeight: 600, cursor: "pointer", letterSpacing: ".04em", transition: "all .12s",
}}>
+ Add Block
</button>
{selectedBlockId && (
<button onClick={() => onRemoveBlock(selectedBlockId)} style={{
padding: "10px 14px", borderRadius: 7, background: "rgba(200,64,64,.15)",
border: `1px solid rgba(200,64,64,.3)`, color: T.red, fontSize: 12,
fontWeight: 600, cursor: "pointer", transition: "all .12s",
}}>
Remove
</button>
)}
</div>
</div>
);
}