// Main app — state, drag/drop, connections, persistence const { useState, useEffect, useRef, useCallback, useMemo } = React; const STORAGE_KEY = 'wattage_v1'; const defaultState = { sources: [], consumers: [], connections: [], // { id, sourceId, consumerId } period: 'monthly' }; const sampleState = { sources: [ { id: 's1', name: 'Day Job', amount: 5200, x: 80, y: 120 }, { id: 's2', name: 'Freelance', amount: 1400, x: 80, y: 420 } ], consumers: [ { id: 'c1', name: 'Mortgage', amount: 1850, x: 520, y: 110, icon: 'M' }, { id: 'c2', name: 'Groceries', amount: 600, x: 520, y: 280, icon: 'G' }, { id: 'c3', name: 'Car Payment', amount: 420, x: 520, y: 440, icon: 'C' }, { id: 'c4', name: 'Utilities', amount: 240, x: 880, y: 110, icon: 'U' }, { id: 'c5', name: 'Streaming', amount: 65, x: 880, y: 280, icon: 'S' }, { id: 'c6', name: 'Vacation Fund', amount: 500, x: 880, y: 440, icon: 'V' } ], connections: [ { id: 'k1', sourceId: 's1', consumerId: 'c1' }, { id: 'k2', sourceId: 's1', consumerId: 'c2' }, { id: 'k3', sourceId: 's1', consumerId: 'c3' }, { id: 'k4', sourceId: 's1', consumerId: 'c4' }, { id: 'k5', sourceId: 's2', consumerId: 'c5' }, { id: 'k6', sourceId: 's2', consumerId: 'c6' } ], period: 'monthly' }; const newId = (p) => p + '_' + Math.random().toString(36).slice(2, 9); // Compute power flow allocations. // For each consumer, divide its draw equally among its connected sources (cap at source remaining). // Actually we use a simple fair model: each consumer's amount is split equally among its active sources; // this models "shared load". When a source is overdrawn, its supplied stays at amount (capped). function computeAllocations(sources, consumers, connections) { // Map source -> list of consumer connections const consumerSources = {}; // consumerId -> [sourceId] const sourceConsumers = {}; // sourceId -> [consumerId] for (const c of consumers) consumerSources[c.id] = []; for (const s of sources) sourceConsumers[s.id] = []; for (const k of connections) { if (consumerSources[k.consumerId]) consumerSources[k.consumerId].push(k.sourceId); if (sourceConsumers[k.sourceId]) sourceConsumers[k.sourceId].push(k.consumerId); } // For each connection, intended draw = consumer.amount / (number of source connections for this consumer) // This is the requested load on each source from this consumer. const requestedByConn = {}; for (const k of connections) { const cons = consumers.find(c => c.id === k.consumerId); const srcCount = consumerSources[k.consumerId]?.length || 1; requestedByConn[k.id] = cons ? cons.amount / srcCount : 0; } // Total requested per source const sourceRequested = {}; for (const s of sources) sourceRequested[s.id] = 0; for (const k of connections) sourceRequested[k.sourceId] += requestedByConn[k.id] || 0; // Source draw = min(requested, capacity) per connection (proportional if overdrawn) const allocPerConn = {}; const sourceDraw = {}; for (const s of sources) { const requested = sourceRequested[s.id]; sourceDraw[s.id] = requested; // show the true requested draw (so overload visible) if (requested <= s.amount || requested === 0) { // No overload: each connection gets its full request for (const k of connections.filter(x => x.sourceId === s.id)) { allocPerConn[k.id] = requestedByConn[k.id]; } } else { // Overload: scale down proportionally — total supply from this source = s.amount const scale = s.amount / requested; for (const k of connections.filter(x => x.sourceId === s.id)) { allocPerConn[k.id] = (requestedByConn[k.id] || 0) * scale; } } } // Consumer supplied = sum of allocPerConn for its connections const consumerSupply = {}; for (const c of consumers) consumerSupply[c.id] = 0; for (const k of connections) { consumerSupply[k.consumerId] += allocPerConn[k.id] || 0; } return { ...allocPerConn, __sourceDraw: sourceDraw, __consumerSupply: consumerSupply }; } const App = () => { const [state, setState] = useState(() => { try { const stored = localStorage.getItem(STORAGE_KEY); if (stored) return { ...defaultState, ...JSON.parse(stored) }; } catch (e) {} return sampleState; }); // Persist useEffect(() => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {} }, [state]); const { sources, consumers, connections, period } = state; // Mobile detection const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.innerWidth < 760); useEffect(() => { const onResize = () => setIsMobile(window.innerWidth < 760); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); // Auto-arrange cards into a stacked column layout on mobile. // We compute display positions from the stored x/y, but on mobile we override. const layout = useMemo(() => { if (!isMobile) { const map = {}; sources.forEach(s => { map[s.id] = { x: s.x, y: s.y }; }); consumers.forEach(c => { map[c.id] = { x: c.x, y: c.y }; }); return map; } // Mobile: scale down + auto-stack. Sources column on left, consumers on right. const w = window.innerWidth; const colSrcX = 8; const colConX = Math.max(150, w - 160 - 8); // 160 is consumer width on mobile const map = {}; const topPad = 110; const srcGapY = 175; const conGapY = 150; sources.forEach((s, i) => { map[s.id] = { x: colSrcX, y: topPad + i * srcGapY }; }); consumers.forEach((c, i) => { map[c.id] = { x: colConX, y: topPad + i * conGapY }; }); return map; }, [isMobile, sources, consumers]); // Cable drag state const [pendingCable, setPendingCable] = useState(null); // { sourceId } const [mousePos, setMousePos] = useState(null); const [hoveredConsumer, setHoveredConsumer] = useState(null); // Card drag state const [dragging, setDragging] = useState(null); // { id, kind, offsetX, offsetY } const canvasRef = useRef(null); const allocations = useMemo( () => computeAllocations(sources, consumers, connections), [sources, consumers, connections] ); // ======================= ACTIONS ======================= const updateSource = (id, patch) => setState(s => ({ ...s, sources: s.sources.map(x => x.id === id ? { ...x, ...patch } : x) })); const updateConsumer = (id, patch) => setState(s => ({ ...s, consumers: s.consumers.map(x => x.id === id ? { ...x, ...patch } : x) })); const deleteSource = (id) => setState(s => ({ ...s, sources: s.sources.filter(x => x.id !== id), connections: s.connections.filter(k => k.sourceId !== id) })); const deleteConsumer = (id) => setState(s => ({ ...s, consumers: s.consumers.filter(x => x.id !== id), connections: s.connections.filter(k => k.consumerId !== id) })); const addSource = () => { const id = newId('s'); // place in left column, stacked const baseY = 120 + sources.length * 220; setState(s => ({ ...s, sources: [...s.sources, { id, name: 'Income Source', amount: 1000, x: 80, y: baseY }] })); }; const addConsumer = () => { const id = newId('c'); const col = consumers.length % 2; const row = Math.floor(consumers.length / 2); setState(s => ({ ...s, consumers: [...s.consumers, { id, name: 'Expense', amount: 200, x: 520 + col * 360, y: 110 + row * 170, icon: 'E' }] })); }; const removeConnection = (connId) => setState(s => ({ ...s, connections: s.connections.filter(k => k.id !== connId) })); const addConnection = (sourceId, consumerId) => { if (connections.find(k => k.sourceId === sourceId && k.consumerId === consumerId)) return; setState(s => ({ ...s, connections: [...s.connections, { id: newId('k'), sourceId, consumerId }] })); }; const setPeriod = (p) => setState(s => ({ ...s, period: p })); const reset = () => { if (!confirm('Clear all sources, consumers and connections?')) return; setState(defaultState); }; const loadSample = () => setState(sampleState); // ======================= POINTER EVENTS (mouse + touch) ======================= // Helper: extract clientX/Y from mouse OR touch event const getPoint = (e) => { if (e.touches && e.touches.length) return { x: e.touches[0].clientX, y: e.touches[0].clientY }; if (e.changedTouches && e.changedTouches.length) return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY }; return { x: e.clientX, y: e.clientY }; }; // Card drag const handleCardMouseDown = useCallback((e, id, kind) => { if (e.button !== undefined && e.button !== 0) return; if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return; // On mobile, cards are auto-laid-out — disable card drag (port drag still works) if (typeof window !== 'undefined' && window.innerWidth < 760) return; const pt = getPoint(e); const rect = e.currentTarget.getBoundingClientRect(); setDragging({ id, kind, offsetX: pt.x - rect.left, offsetY: pt.y - rect.top }); if (e.cancelable) e.preventDefault(); }, []); // Port drag start const handlePortMouseDown = useCallback((e, sourceId) => { if (e.button !== undefined && e.button !== 0) return; const pt = getPoint(e); setPendingCable({ sourceId }); setMousePos(pt); if (e.cancelable) e.preventDefault(); }, []); useEffect(() => { const onMove = (e) => { const pt = getPoint(e); const x = pt.x; const y = pt.y; if (dragging) { const newX = x - dragging.offsetX; const newY = y - dragging.offsetY; if (dragging.kind === 'source') updateSource(dragging.id, { x: newX, y: newY }); else updateConsumer(dragging.id, { x: newX, y: newY }); if (e.cancelable) e.preventDefault(); } if (pendingCable) { setMousePos({ x, y }); const el = document.elementFromPoint(x, y); const card = el?.closest?.('[data-consumer-id]'); if (card) setHoveredConsumer(card.getAttribute('data-consumer-id')); else setHoveredConsumer(null); if (e.cancelable) e.preventDefault(); } }; const onUp = (e) => { if (pendingCable) { if (hoveredConsumer) { addConnection(pendingCable.sourceId, hoveredConsumer); } setPendingCable(null); setMousePos(null); setHoveredConsumer(null); } if (dragging) setDragging(null); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); window.addEventListener('touchmove', onMove, { passive: false }); window.addEventListener('touchend', onUp); window.addEventListener('touchcancel', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); window.removeEventListener('touchmove', onMove); window.removeEventListener('touchend', onUp); window.removeEventListener('touchcancel', onUp); }; }, [dragging, pendingCable, hoveredConsumer]); // Totals const totalIncome = sources.reduce((a, s) => a + (s.amount || 0), 0); const totalCommitted = consumers.reduce((a, c) => a + (c.amount || 0), 0); const totalSupplied = consumers.reduce((a, c) => a + (allocations.__consumerSupply[c.id] || 0), 0); return (
{/* Cables BEHIND cards */} ({ ...s, ...layout[s.id] }))} consumers={consumers.map(c => ({ ...c, ...layout[c.id] }))} connections={connections} allocations={allocations} pendingCable={pendingCable} mousePos={mousePos} onCableClick={removeConnection} period={period} isMobile={isMobile} /> {/* Sources */} {sources.map(s => ( ))} {/* Consumers */} {consumers.map(c => ( ))} {/* Empty state */} {sources.length === 0 && consumers.length === 0 && (
// GRID OFFLINE
Add a power source to begin
Drop in your income, plug in your expenses, and watch the grid balance itself.
)} {/* Summary */} {(sources.length > 0 || consumers.length > 0) && ( )} {/* Help hint */} {connections.length === 0 && sources.length > 0 && consumers.length > 0 && (
Drag from a battery's output port to a consumer to connect.
Click a cable to disconnect.
)}
); }; // Mount const root = ReactDOM.createRoot(document.getElementById('root')); root.render();