Files
Space-Game/archive/legacy-static/js/demos/market.js
francy51 316a44661b Restructure into pnpm monorepo with game shell, docs, and SpacetimeDB backend
- Restructure flat static prototype into pnpm workspace monorepo
- apps/game: playable shell with R3F 3D scene, HUD, SpacetimeDB connection
- apps/docs: design docs and prototypes
- apps/site: landing page
- packages/ui: shared Button and Panel primitives
- services/spacetimedb: backend module (9 tables, 11 reducers)
- Archive legacy static files to archive/legacy-static/
- Game loop: connect, undock, target, approach, dock, mine, sell
- Add pnpm-workspace.yaml, tsconfig.base.json, spacetime.json
2026-05-31 17:56:56 -04:00

1183 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
window.GDD = window.GDD || {};
const { useState, useEffect, useRef, useCallback, useMemo } = React;
/* ──────────────────────────────────────────────
Commodities Exchange — Contract Market
────────────────────────────────────────────── */
/* ── Static contract catalogue ── */
const CATEGORIES = [
{ id: 'all', label: 'All Contracts' },
{ id: 'ore', label: 'Raw Ores' },
{ id: 'mineral', label: 'Refined Minerals' },
{ id: 'gas', label: 'Gas Products' },
{ id: 'isotope', label: 'Isotopes' },
{ id: 'exotic', label: 'Exotic Matter' },
];
const CONTRACTS = [
/* Ores */
{ symbol:'VLD', name:'Veldspar', category:'ore', lotSize:1000, tickSize:0.01, marginPct:8, basePrice:14, hub:'Jita IV Moon 4' },
{ symbol:'SCR', name:'Scordite', category:'ore', lotSize:500, tickSize:0.02, marginPct:10, basePrice:32, hub:'Jita IV Moon 4' },
{ symbol:'PYX', name:'Pyroxeres', category:'ore', lotSize:500, tickSize:0.05, marginPct:10, basePrice:45, hub:'Amarr VIII Emperor' },
{ symbol:'KRN', name:'Kernite', category:'ore', lotSize:250, tickSize:0.10, marginPct:12, basePrice:90, hub:'Amarr VIII Emperor' },
{ symbol:'OMB', name:'Omber', category:'ore', lotSize:250, tickSize:0.10, marginPct:12, basePrice:135, hub:'Rens VI Moon 8' },
{ symbol:'JSP', name:'Jaspet', category:'ore', lotSize:200, tickSize:0.10, marginPct:15, basePrice:190, hub:'Dodixie IX Moon 20' },
{ symbol:'HEM', name:'Hemorphite', category:'ore', lotSize:100, tickSize:0.50, marginPct:18, basePrice:360, hub:'Jita IV Moon 4' },
{ symbol:'ARK', name:'Arkonor', category:'ore', lotSize:50, tickSize:1.00, marginPct:20, basePrice:620, hub:'Jita IV Moon 4' },
/* Minerals */
{ symbol:'TRI', name:'Tritanium', category:'mineral', lotSize:5000, tickSize:0.01, marginPct:6, basePrice:5, hub:'Jita IV Moon 4' },
{ symbol:'PYE', name:'Pyerite', category:'mineral', lotSize:2000, tickSize:0.01, marginPct:8, basePrice:12, hub:'Jita IV Moon 4' },
{ symbol:'MLX', name:'Mexallon', category:'mineral', lotSize:1000, tickSize:0.02, marginPct:8, basePrice:35, hub:'Jita IV Moon 4' },
{ symbol:'ISO', name:'Isogen', category:'mineral', lotSize:500, tickSize:0.05, marginPct:12, basePrice:110, hub:'Amarr VIII Emperor' },
{ symbol:'NCX', name:'Nocxium', category:'mineral', lotSize:250, tickSize:0.10, marginPct:15, basePrice:380, hub:'Jita IV Moon 4' },
{ symbol:'ZDR', name:'Zydrine', category:'mineral', lotSize:100, tickSize:0.50, marginPct:18, basePrice:950, hub:'Jita IV Moon 4' },
{ symbol:'MEG', name:'Megacyte', category:'mineral', lotSize:50, tickSize:1.00, marginPct:20, basePrice:2800,hub:'Jita IV Moon 4' },
{ symbol:'MOR', name:'Morphite', category:'mineral', lotSize:10, tickSize:5.00, marginPct:25, basePrice:8500,hub:'Jita IV Moon 4' },
/* Gas */
{ symbol:'ATM', name:'Atmospheric', category:'gas', lotSize:2000, tickSize:0.01, marginPct:10, basePrice:8, hub:'Jita IV Moon 4' },
{ symbol:'NEB', name:'Nebular', category:'gas', lotSize:500, tickSize:0.05, marginPct:12, basePrice:45, hub:'Amarr VIII Emperor' },
{ symbol:'ION', name:'Ionized', category:'gas', lotSize:200, tickSize:0.10, marginPct:15, basePrice:120, hub:'Jita IV Moon 4' },
{ symbol:'FUL', name:'Fullerides', category:'gas', lotSize:100, tickSize:0.50, marginPct:18, basePrice:450, hub:'Jita IV Moon 4' },
/* Isotopes */
{ symbol:'H3', name:'Hydrogen-3', category:'isotope', lotSize:500, tickSize:0.05, marginPct:15, basePrice:85, hub:'Jita IV Moon 4' },
{ symbol:'HE4', name:'Helium-4', category:'isotope', lotSize:500, tickSize:0.05, marginPct:15, basePrice:92, hub:'Amarr VIII Emperor' },
{ symbol:'N15', name:'Nitrogen-15', category:'isotope', lotSize:500, tickSize:0.05, marginPct:15, basePrice:78, hub:'Rens VI Moon 8' },
{ symbol:'O18', name:'Oxygen-18', category:'isotope', lotSize:500, tickSize:0.05, marginPct:15, basePrice:88, hub:'Dodixie IX Moon 20' },
/* Exotic */
{ symbol:'RDB', name:'Reedstone', category:'exotic', lotSize:10, tickSize:5.00, marginPct:25, basePrice:12000,hub:'Jita IV Moon 4' },
{ symbol:'TCH', name:'Tachyon Salt', category:'exotic', lotSize:5, tickSize:10.0, marginPct:30, basePrice:45000,hub:'Jita IV Moon 4' },
];
/* ── Sparkline ── */
function Sparkline({ data, width = 80, height = 24, color = 'var(--green)' }) {
const ref = useRef(null);
useEffect(() => {
const c = ref.current; if (!c || !data || data.length < 2) return;
const ctx = c.getContext('2d');
const dpr = window.devicePixelRatio || 1;
c.width = width * dpr; c.height = height * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, width, height);
const min = Math.min(...data); const max = Math.max(...data);
const range = max - min || 1;
const step = width / (data.length - 1);
ctx.beginPath();
data.forEach((v, i) => {
const x = i * step;
const y = height - ((v - min) / range) * (height - 4) - 2;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.lineJoin = 'round';
ctx.stroke();
const grad = ctx.createLinearGradient(0, 0, 0, height);
const isGreen = color.includes('green');
grad.addColorStop(0, isGreen ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)');
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.lineTo(width, height); ctx.lineTo(0, height); ctx.closePath();
ctx.fillStyle = grad; ctx.fill();
}, [data, width, height, color]);
return <canvas ref={ref} style={{ width, height, display: 'block' }} />;
}
/* ── Ticker Tape ── */
function TickerTape({ items }) {
const [offset, setOffset] = useState(0);
const raf = useRef(null);
useEffect(() => {
const speed = 0.5;
const tick = () => {
setOffset(o => { const n = o - speed; return n < -3000 ? 0 : n; });
raf.current = requestAnimationFrame(tick);
};
raf.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf.current);
}, []);
const dup = [...items, ...items, ...items];
return (
<div style={{
overflow: 'hidden', whiteSpace: 'nowrap',
background: 'rgba(10,16,28,0.95)', borderBottom: '1px solid var(--border)',
padding: '5px 0', fontFamily: 'var(--font-mono)', fontSize: '0.65rem',
}}>
<div style={{ display: 'inline-flex', transform: `translateX(${offset}px)`, willChange: 'transform' }}>
{dup.map((t, i) => (
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: '5px', padding: '0 14px', borderRight: '1px solid var(--border)' }}>
<span style={{ color: 'var(--fg-bright)', fontWeight: 600 }}>{t.symbol}</span>
<span style={{ color: 'var(--fg-dim)' }}>{t.price.toLocaleString()}</span>
<span style={{ color: t.change >= 0 ? 'var(--green)' : 'var(--red)', fontWeight: 600 }}>
{t.change >= 0 ? '▲' : '▼'} {Math.abs(t.changePct).toFixed(1)}%
</span>
</span>
))}
</div>
</div>
);
}
/* ── Category Tabs ── */
function CategoryTabs({ active, onChange }) {
return (
<div style={{
display: 'flex', gap: '2px', padding: 'var(--sp-2) 0',
borderBottom: '1px solid var(--border)', marginBottom: 'var(--sp-4)',
overflowX: 'auto',
}}>
{CATEGORIES.map(cat => (
<button key={cat.id} onClick={() => onChange(cat.id)} style={{
padding: '6px 14px', border: 'none', borderRadius: 'var(--radius-md)',
background: active === cat.id ? 'var(--accent-bg)' : 'transparent',
color: active === cat.id ? 'var(--accent)' : 'var(--muted)',
border: active === cat.id ? '1px solid var(--accent-border)' : '1px solid transparent',
fontSize: '0.72rem', fontFamily: 'var(--font-mono)', cursor: 'pointer',
transition: 'all var(--transition-fast)', whiteSpace: 'nowrap',
fontWeight: active === cat.id ? 600 : 400,
letterSpacing: '0.03em',
}}>
{cat.label}
</button>
))}
</div>
);
}
/* ── Contract Board — main table ── */
function ContractBoard({ commodities, selected, onSelect, category }) {
const filtered = category === 'all' ? commodities : commodities.filter(c => c.category === category);
return (
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)', overflow: 'hidden',
}}>
{/* Table header */}
<div style={{
display: 'grid',
gridTemplateColumns: '90px 120px 80px 60px 70px 70px 80px 90px 70px',
gap: '4px', alignItems: 'center',
padding: '8px 12px', borderBottom: '1px solid var(--border)',
background: 'var(--surface-raised)',
fontFamily: 'var(--font-mono)', fontSize: '0.6rem', color: 'var(--muted)',
textTransform: 'uppercase', letterSpacing: '0.05em',
}}>
<span>Ticker</span><span>Name</span><span>Last</span><span>Chg%</span>
<span>Bid</span><span>Ask</span><span>Volume</span><span>Open Int.</span><span />
</div>
{/* Rows */}
<div style={{ maxHeight: '380px', overflowY: 'auto', overscrollBehavior: 'contain' }}>
{filtered.map(c => {
const up = c.change >= 0;
const isSel = selected === c.symbol;
const spread = c.bestAsk - c.bestBid;
return (
<div key={c.symbol} onClick={() => onSelect(c.symbol)} style={{
display: 'grid',
gridTemplateColumns: '90px 120px 80px 60px 70px 70px 80px 90px 70px',
gap: '4px', alignItems: 'center',
padding: '7px 12px', cursor: 'pointer',
background: isSel ? 'var(--surface-hover)' : 'transparent',
borderLeft: isSel ? '2px solid var(--accent)' : '2px solid transparent',
borderBottom: '1px solid var(--border)',
transition: 'background var(--transition-fast)',
fontSize: '0.75rem',
}}>
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 700, color: 'var(--accent)' }}>{c.symbol}</span>
<span style={{ color: 'var(--fg-dim)', fontSize: '0.7rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{c.name}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 700, color: 'var(--fg-bright)' }}>{c.price.toLocaleString()}</span>
<span style={{
fontFamily: 'var(--font-mono)', fontSize: '0.65rem', fontWeight: 600,
color: up ? 'var(--green)' : 'var(--red)',
background: up ? 'var(--green-bg)' : 'var(--red-bg)',
padding: '1px 5px', borderRadius: 'var(--radius-pill)', display: 'inline-block',
}}>
{up ? '+' : ''}{c.changePct.toFixed(1)}%
</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: 'var(--green)' }}>{c.bestBid}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: 'var(--red)' }}>{c.bestAsk}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: 'var(--fg-dim)' }}>{(c.volume / 1000).toFixed(1)}K</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: 'var(--cyan)' }}>{(c.openInterest / 1000).toFixed(1)}K</span>
<span><Sparkline data={c.history} width={60} height={18} color={up ? 'var(--green)' : 'var(--red)'} /></span>
</div>
);
})}
</div>
</div>
);
}
/* ── Contract Spec Panel ── */
function ContractSpec({ contract, commodity }) {
if (!contract || !commodity) return null;
const marginPerLot = Math.ceil(commodity.price * contract.lotSize * (contract.marginPct / 100));
const notionalPerLot = commodity.price * contract.lotSize;
return (
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)', overflow: 'hidden',
}}>
<div style={{
padding: '10px 14px', borderBottom: '1px solid var(--border)',
background: 'var(--surface-raised)',
fontFamily: 'var(--font-mono)', fontSize: '0.65rem', color: 'var(--muted)',
textTransform: 'uppercase', letterSpacing: '0.06em',
}}>
Contract Specification
</div>
<div style={{ padding: 'var(--sp-3) var(--sp-4)', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 16px' }}>
{[
['Ticker', contract.symbol, 'var(--accent)'],
['Unit', contract.name, 'var(--fg-bright)'],
['Lot Size', contract.lotSize.toLocaleString() + ' units', 'var(--fg-bright)'],
['Tick Size', '₢' + contract.tickSize, 'var(--fg-bright)'],
['Margin Req.', contract.marginPct + '%', 'var(--cyan)'],
['Margin / Lot', '₢' + marginPerLot.toLocaleString(), 'var(--cyan)'],
['Notional / Lot', '₢' +notionalPerLot.toLocaleString(), 'var(--fg-dim)'],
['Delivery', contract.hub, 'var(--fg-dim)'],
['Settlement', commodity.settlement ? '₢' + commodity.settlement : '—', 'var(--fg-dim)'],
['Expiry', '30 DTE', 'var(--muted)'],
['Supply', (commodity.supply / 1000).toFixed(0) + 'K/day', 'var(--green)'],
['Demand', (commodity.demand / 1000).toFixed(0) + 'K/day', commodity.demand > commodity.supply ? 'var(--red)' : 'var(--green)'],
].map(([label, value, color], i) => (
<React.Fragment key={i}>
<span style={{ fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>{label}</span>
<span style={{ fontSize: '0.7rem', color, fontFamily: 'var(--font-mono)', fontWeight: 500, textAlign: 'right' }}>{value}</span>
</React.Fragment>
))}
</div>
{/* Supply/Demand bar */}
<div style={{ padding: '0 var(--sp-4) var(--sp-3)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.6rem', color: 'var(--muted)', marginBottom: '3px', fontFamily: 'var(--font-mono)' }}>
<span>SUPPLY</span>
<span style={{ color: commodity.demand > commodity.supply ? 'var(--red)' : 'var(--green)' }}>
{commodity.demand > commodity.supply ? 'DEFICIT' : 'SURPLUS'}: {Math.abs(commodity.supply - commodity.demand).toLocaleString()}
</span>
<span>DEMAND</span>
</div>
<div style={{ display: 'flex', height: '6px', borderRadius: '3px', overflow: 'hidden', background: 'var(--surface-raised)' }}>
<div style={{ width: `${(commodity.supply / Math.max(commodity.supply, commodity.demand)) * 100}%`, background: 'var(--green-dim)', borderRadius: '3px 0 0 3px' }} />
<div style={{ width: `${(commodity.demand / Math.max(commodity.supply, commodity.demand)) * 100}%`, background: 'var(--red-dim)', borderRadius: '0 3px 3px 0', opacity: 0.7 }} />
</div>
</div>
</div>
);
}
/* ── Depth Chart (canvas) ── */
function DepthChart({ bids, asks, midPrice }) {
const ref = useRef(null);
useEffect(() => {
const c = ref.current; if (!c) return;
const ctx = c.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const W = c.clientWidth; const H = c.clientHeight;
c.width = W * dpr; c.height = H * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
if (!bids.length || !asks.length) return;
const maxCum = Math.max(
bids.reduce((s, b) => Math.max(s, b.cumulative), 0),
asks.reduce((s, a) => Math.max(s, a.cumulative), 0)
);
const priceRange = midPrice * 0.06;
const pMin = midPrice - priceRange;
const pMax = midPrice + priceRange;
const toX = p => ((p - pMin) / (pMax - pMin)) * W;
const toY = cum => H - (cum / (maxCum * 1.1)) * H;
/* Grid */
ctx.strokeStyle = 'rgba(28,42,63,0.4)'; ctx.lineWidth = 0.5;
for (let i = 0; i < 4; i++) {
const y = (H / 3) * i;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
/* Bids (green area, left of mid) */
ctx.beginPath();
ctx.moveTo(toX(bids[0].price), H);
bids.forEach(b => ctx.lineTo(toX(b.price), toY(b.cumulative)));
ctx.lineTo(toX(bids[bids.length - 1].price), H);
ctx.closePath();
const bg = ctx.createLinearGradient(0, 0, 0, H);
bg.addColorStop(0, 'rgba(34,197,94,0.25)'); bg.addColorStop(1, 'rgba(34,197,94,0.03)');
ctx.fillStyle = bg; ctx.fill();
/* Asks (red area, right of mid) */
ctx.beginPath();
ctx.moveTo(toX(asks[0].price), H);
asks.forEach(a => ctx.lineTo(toX(a.price), toY(a.cumulative)));
ctx.lineTo(toX(asks[asks.length - 1].price), H);
ctx.closePath();
const ag = ctx.createLinearGradient(0, 0, 0, H);
ag.addColorStop(0, 'rgba(239,68,68,0.25)'); ag.addColorStop(1, 'rgba(239,68,68,0.03)');
ctx.fillStyle = ag; ctx.fill();
/* Mid line */
const mx = toX(midPrice);
ctx.setLineDash([3, 3]);
ctx.beginPath(); ctx.moveTo(mx, 0); ctx.lineTo(mx, H);
ctx.strokeStyle = 'var(--accent)'; ctx.lineWidth = 1; ctx.stroke();
ctx.setLineDash([]);
/* Price label */
ctx.fillStyle = 'var(--accent)'; ctx.font = 'bold 9px var(--font-mono)';
ctx.textAlign = 'center';
ctx.fillText('₢' + midPrice.toFixed(0), mx, H - 4);
}, [bids, asks, midPrice]);
return <canvas ref={ref} style={{ width: '100%', height: '100px', display: 'block' }} />;
}
/* ── Order Book ── */
function OrderBook({ bids, asks, spread, spreadPct }) {
const maxBid = Math.max(...bids.map(b => b.volume));
const maxAsk = Math.max(...asks.map(a => a.volume));
return (
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)', overflow: 'hidden',
}}>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '8px 12px', borderBottom: '1px solid var(--border)',
background: 'var(--surface-raised)',
}}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.65rem', color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Market Depth
</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.65rem', color: 'var(--accent)' }}>
Spread: {spread} ({spreadPct.toFixed(2)}%)
</span>
</div>
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr 1fr',
fontFamily: 'var(--font-mono)', fontSize: '0.58rem', color: 'var(--muted)',
textTransform: 'uppercase', letterSpacing: '0.05em',
padding: '5px 12px', borderBottom: '1px solid var(--border)',
}}>
<span>Price</span><span style={{ textAlign: 'center' }}>Size</span><span style={{ textAlign: 'right' }}>Cum</span>
</div>
{/* Asks (reversed) */}
<div style={{ maxHeight: '160px', overflowY: 'auto', overscrollBehavior: 'contain' }}>
{[...asks].reverse().map((a, i) => (
<div key={`a${i}`} style={{ position: 'relative', display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', padding: '2px 12px', fontFamily: 'var(--font-mono)', fontSize: '0.68rem' }}>
<div style={{ position: 'absolute', inset: 0, background: 'rgba(239,68,68,0.07)', width: `${(a.volume / maxAsk) * 100}%`, right: 0, left: 'auto' }} />
<span style={{ color: 'var(--red)', position: 'relative' }}>{a.price}</span>
<span style={{ color: 'var(--fg-dim)', textAlign: 'center', position: 'relative' }}>{a.volume.toLocaleString()}</span>
<span style={{ color: 'var(--muted)', textAlign: 'right', position: 'relative' }}>{a.cumulative.toLocaleString()}</span>
</div>
))}
</div>
{/* Mid */}
<div style={{
padding: '5px 12px', background: 'var(--surface-raised)',
borderTop: '1px solid var(--border)', borderBottom: '1px solid var(--border)',
display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 'var(--sp-2)',
}}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.85rem', fontWeight: 700, color: 'var(--fg-bright)' }}>
{((bids[0]?.price || 0 + asks[0]?.price || 0) / 2).toLocaleString()}
</span>
<span style={{ fontSize: '0.58rem', color: 'var(--muted)' }}>MID</span>
</div>
{/* Bids */}
<div style={{ maxHeight: '160px', overflowY: 'auto', overscrollBehavior: 'contain' }}>
{bids.map((b, i) => (
<div key={`b${i}`} style={{ position: 'relative', display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', padding: '2px 12px', fontFamily: 'var(--font-mono)', fontSize: '0.68rem' }}>
<div style={{ position: 'absolute', inset: 0, background: 'rgba(34,197,94,0.07)', width: `${(b.volume / maxBid) * 100}%` }} />
<span style={{ color: 'var(--green)', position: 'relative' }}>{b.price}</span>
<span style={{ color: 'var(--fg-dim)', textAlign: 'center', position: 'relative' }}>{b.volume.toLocaleString()}</span>
<span style={{ color: 'var(--muted)', textAlign: 'right', position: 'relative' }}>{b.cumulative.toLocaleString()}</span>
</div>
))}
</div>
</div>
);
}
/* ── Price Chart (canvas) ── */
function PriceChart({ data, symbol }) {
const ref = useRef(null);
useEffect(() => {
const c = ref.current; if (!c || !data || data.length < 2) return;
const ctx = c.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const W = c.clientWidth; const H = c.clientHeight;
c.width = W * dpr; c.height = H * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
const prices = data.map(d => d.close);
const highs = data.map(d => d.high);
const lows = data.map(d => d.low);
const volumes = data.map(d => d.volume);
const allP = [...highs, ...lows];
const minP = Math.min(...allP); const maxP = Math.max(...allP);
const rangeP = maxP - minP || 1;
const maxV = Math.max(...volumes);
const chartH = H * 0.72; const volH = H * 0.22; const barW = Math.max(2, (W / data.length) - 1);
/* Grid */
ctx.strokeStyle = 'rgba(28,42,63,0.5)'; ctx.lineWidth = 0.5;
for (let i = 0; i < 5; i++) {
const y = (chartH / 4) * i;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
ctx.fillStyle = 'var(--muted)'; ctx.font = '8px var(--font-mono)';
ctx.fillText('₢' + (maxP - (rangeP / 4) * i).toFixed(0), W - 42, y + 9);
}
/* Volume bars */
data.forEach((d, i) => {
const x = (i / data.length) * W;
const vh = (d.volume / maxV) * volH;
ctx.fillStyle = d.close >= d.open ? 'rgba(34,197,94,0.18)' : 'rgba(239,68,68,0.18)';
ctx.fillRect(x, H - vh, barW, vh);
});
/* Price line */
ctx.beginPath();
data.forEach((d, i) => {
const x = (i / (data.length - 1)) * W;
const y = chartH - ((d.close - minP) / rangeP) * (chartH - 10) - 5;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
const lastC = data[data.length - 1].close;
const firstC = data[0].close;
const lineColor = lastC >= firstC ? '#22c55e' : '#ef4444';
ctx.strokeStyle = lineColor; ctx.lineWidth = 1.8; ctx.lineJoin = 'round'; ctx.stroke();
/* Area fill */
const lastX = W;
ctx.lineTo(lastX, chartH); ctx.lineTo(0, chartH); ctx.closePath();
const grad = ctx.createLinearGradient(0, 0, 0, chartH);
grad.addColorStop(0, lastC >= firstC ? 'rgba(34,197,94,0.10)' : 'rgba(239,68,68,0.10)');
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grad; ctx.fill();
/* Current price line */
const curY = chartH - ((lastC - minP) / rangeP) * (chartH - 10) - 5;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(0, curY); ctx.lineTo(W, curY);
ctx.strokeStyle = lineColor; ctx.lineWidth = 1; ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = lineColor;
ctx.beginPath(); ctx.roundRect(W - 55, curY - 8, 52, 16, 3); ctx.fill();
ctx.fillStyle = '#080c14'; ctx.font = 'bold 8px var(--font-mono)';
ctx.fillText('₢' + lastC.toFixed(0), W - 52, curY + 3);
}, [data, symbol]);
return <canvas ref={ref} style={{ width: '100%', height: '240px', display: 'block' }} />;
}
/* ── Order Form (commodities-style: lots, margin, long/short) ── */
function OrderForm({ contract, commodity, credits, positions, onTrade }) {
const [direction, setDirection] = useState('long'); // long or short
const [orderType, setOrderType] = useState('market'); // market, limit, stop
const [lots, setLots] = useState('');
const [limitPrice, setLimitPrice] = useState('');
const [error, setError] = useState('');
const [confirmation, setConfirmation] = useState(null);
if (!contract || !commodity) return null;
const effectivePrice = orderType === 'market' ? commodity.price : (parseFloat(limitPrice) || commodity.price);
const numLots = parseInt(lots) || 0;
const totalUnits = numLots * contract.lotSize;
const notional = totalUnits * effectivePrice;
const marginRequired = Math.ceil(notional * (contract.marginPct / 100));
const commission = Math.ceil(notional * 0.015);
const pos = positions.find(p => p.symbol === contract.symbol);
const existingDirection = pos ? pos.direction : null;
const canAfford = marginRequired + commission <= credits;
const handleSubmit = () => {
if (numLots <= 0) { setError('Enter lot quantity'); return; }
if (orderType !== 'market' && !limitPrice) { setError('Set limit/stop price'); return; }
if (!canAfford) { setError('Insufficient margin + commission'); return; }
setError('');
setConfirmation({ direction, orderType, lots: numLots, price: effectivePrice, notional, marginRequired, commission, totalUnits });
};
const confirmTrade = () => {
onTrade(direction, contract.symbol, effectivePrice, numLots, totalUnits, marginRequired, commission);
setConfirmation(null); setLots(''); setLimitPrice(''); setError('');
};
return (
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)', overflow: 'hidden',
}}>
<div style={{
padding: '8px 12px', borderBottom: '1px solid var(--border)',
background: 'var(--surface-raised)',
fontFamily: 'var(--font-mono)', fontSize: '0.65rem', color: 'var(--muted)',
textTransform: 'uppercase', letterSpacing: '0.06em',
display: 'flex', justifyContent: 'space-between',
}}>
<span>Place Order {contract.symbol}</span>
<span>Lot: {contract.lotSize.toLocaleString()} units</span>
</div>
<div style={{ padding: 'var(--sp-3) var(--sp-4)' }}>
{/* Long / Short toggle */}
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '3px',
marginBottom: 'var(--sp-3)', background: 'var(--surface-raised)',
borderRadius: 'var(--radius-md)', padding: '3px',
}}>
{['long', 'short'].map(d => (
<button key={d} onClick={() => setDirection(d)} style={{
padding: '7px', border: 'none', borderRadius: 'var(--radius-md)',
background: direction === d ? (d === 'long' ? 'var(--green-bg)' : 'var(--red-bg)') : 'transparent',
color: direction === d ? (d === 'long' ? 'var(--green)' : 'var(--red)') : 'var(--muted)',
fontWeight: 600, fontSize: '0.75rem', cursor: 'pointer', fontFamily: 'var(--font-mono)',
transition: 'all var(--transition-fast)',
border: direction === d ? `1px solid ${d === 'long' ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}` : '1px solid transparent',
}}>
{d === 'long' ? '▲ LONG' : '▼ SHORT'}
</button>
))}
</div>
{/* Order type pills */}
<div style={{ display: 'flex', gap: 'var(--sp-2)', marginBottom: 'var(--sp-3)' }}>
{['market', 'limit', 'stop'].map(t => (
<button key={t} onClick={() => setOrderType(t)} style={{
padding: '3px 10px', border: `1px solid ${orderType === t ? 'var(--accent-border)' : 'var(--border)'}`,
borderRadius: 'var(--radius-pill)', background: orderType === t ? 'var(--accent-bg)' : 'transparent',
color: orderType === t ? 'var(--accent)' : 'var(--muted)',
fontSize: '0.65rem', fontFamily: 'var(--font-mono)', cursor: 'pointer', transition: 'all var(--transition-fast)',
}}>
{t.toUpperCase()}
</button>
))}
</div>
{/* Limit/Stop price */}
{orderType !== 'market' && (
<div style={{ marginBottom: 'var(--sp-3)' }}>
<label style={{ fontSize: '0.6rem', color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: '3px' }}>
{orderType === 'limit' ? 'Limit Price' : 'Stop Price'}
</label>
<input type="number" value={limitPrice} onChange={e => setLimitPrice(e.target.value)}
placeholder={`Spot: ₢${commodity.price}`}
style={{
width: '100%', padding: '7px 10px', background: 'var(--bg)',
border: '1px solid var(--border)', borderRadius: 'var(--radius-md)',
color: 'var(--fg)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem',
outline: 'none', boxSizing: 'border-box',
}}
/>
</div>
)}
{/* Lots */}
<div style={{ marginBottom: 'var(--sp-3)' }}>
<label style={{ fontSize: '0.6rem', color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: '3px' }}>
Lots (× {contract.lotSize.toLocaleString()} units)
</label>
<div style={{ display: 'flex', gap: '3px' }}>
<input type="number" value={lots} onChange={e => setLots(e.target.value)}
placeholder="0" min="1"
style={{
flex: 1, padding: '7px 10px', background: 'var(--bg)',
border: '1px solid var(--border)', borderRadius: 'var(--radius-md)',
color: 'var(--fg)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', outline: 'none',
}}
/>
{[1, 5, 10, 25].map(n => (
<button key={n} onClick={() => setLots(String(n))} style={{
padding: '7px 5px', border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)', background: 'var(--surface-raised)',
color: 'var(--fg-dim)', fontSize: '0.6rem', fontFamily: 'var(--font-mono)',
cursor: 'pointer', transition: 'all var(--transition-fast)',
}}>{n}</button>
))}
</div>
</div>
{/* Summary */}
<div style={{
fontFamily: 'var(--font-mono)', fontSize: '0.68rem', color: 'var(--fg-dim)',
background: 'var(--bg)', borderRadius: 'var(--radius-md)', padding: 'var(--sp-3)',
marginBottom: 'var(--sp-3)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '3px' }}>
<span>Price / Unit</span><span style={{ color: 'var(--fg-bright)' }}>{effectivePrice.toLocaleString()}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '3px' }}>
<span>Total Units</span><span style={{ color: 'var(--fg-bright)' }}>{totalUnits.toLocaleString()}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '3px' }}>
<span>Notional Value</span><span style={{ color: 'var(--fg-bright)' }}>{notional.toLocaleString()}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '3px' }}>
<span>Margin ({contract.marginPct}%)</span><span style={{ color: 'var(--cyan)' }}>{marginRequired.toLocaleString()}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '3px' }}>
<span>Commission</span><span style={{ color: 'var(--accent)' }}>{commission.toLocaleString()}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', paddingTop: '5px', borderTop: '1px solid var(--border)', fontWeight: 700 }}>
<span>Required</span>
<span style={{ color: direction === 'long' ? 'var(--green)' : 'var(--red)' }}>
{(marginRequired + commission).toLocaleString()}
</span>
</div>
</div>
{error && <div style={{ fontSize: '0.7rem', color: 'var(--red)', marginBottom: 'var(--sp-2)', padding: '5px 10px', background: 'var(--red-bg)', borderRadius: 'var(--radius-md)' }}>{error}</div>}
<button onClick={handleSubmit} disabled={!lots || !canAfford} style={{
width: '100%', padding: '9px', border: 'none', borderRadius: 'var(--radius-md)',
background: direction === 'long' ? 'var(--green-dim)' : 'var(--red-dim)',
color: 'var(--fg-bright)', fontWeight: 700, fontSize: '0.8rem',
cursor: canAfford && lots ? 'pointer' : 'not-allowed',
opacity: canAfford && lots ? 1 : 0.4,
fontFamily: 'var(--font-mono)', transition: 'all var(--transition-fast)',
}}>
{direction === 'long' ? '▲ BUY' : '▼ SELL'} {numLots}× {contract.symbol}
</button>
</div>
{/* Confirmation */}
{confirmation && (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2000,
}} onClick={() => setConfirmation(null)}>
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)', padding: 'var(--sp-5)', minWidth: 320,
}} onClick={e => e.stopPropagation()}>
<h3 style={{ marginBottom: 'var(--sp-4)', fontSize: '0.9rem' }}>Confirm {confirmation.orderType} Order</h3>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.78rem', lineHeight: 2 }}>
<div><span style={{ color: 'var(--muted)' }}>Direction:</span> <span style={{ color: confirmation.direction === 'long' ? 'var(--green)' : 'var(--red)', fontWeight: 700 }}>{confirmation.direction.toUpperCase()}</span></div>
<div><span style={{ color: 'var(--muted)' }}>Contract:</span> <span style={{ color: 'var(--fg-bright)' }}>{contract.symbol} ({contract.name})</span></div>
<div><span style={{ color: 'var(--muted)' }}>Lots:</span> <span style={{ color: 'var(--fg-bright)' }}>{confirmation.lots} ({confirmation.totalUnits.toLocaleString()} units)</span></div>
<div><span style={{ color: 'var(--muted)' }}>Price:</span> <span style={{ color: 'var(--fg-bright)' }}>{confirmation.price.toLocaleString()}/unit</span></div>
<div><span style={{ color: 'var(--muted)' }}>Notional:</span> <span style={{ color: 'var(--fg-bright)' }}>{confirmation.notional.toLocaleString()}</span></div>
<div><span style={{ color: 'var(--muted)' }}>Margin:</span> <span style={{ color: 'var(--cyan)' }}>{confirmation.marginRequired.toLocaleString()}</span></div>
<div><span style={{ color: 'var(--muted)' }}>Commission:</span> <span style={{ color: 'var(--accent)' }}>{confirmation.commission.toLocaleString()}</span></div>
</div>
<div style={{ display: 'flex', gap: 'var(--sp-3)', marginTop: 'var(--sp-5)' }}>
<button onClick={confirmTrade} style={{
flex: 1, padding: '9px', border: 'none', borderRadius: 'var(--radius-md)',
background: confirmation.direction === 'long' ? 'var(--green-dim)' : 'var(--red-dim)',
color: 'var(--fg-bright)', fontWeight: 700, fontSize: '0.8rem', cursor: 'pointer',
fontFamily: 'var(--font-mono)',
}}>CONFIRM</button>
<button onClick={() => setConfirmation(null)} style={{
flex: 1, padding: '9px', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)',
background: 'var(--surface-raised)', color: 'var(--fg-dim)', fontSize: '0.8rem',
cursor: 'pointer', fontFamily: 'var(--font-body)',
}}>Cancel</button>
</div>
</div>
</div>
)}
</div>
);
}
/* ── Positions Panel (margin account + open positions) ── */
function PositionsPanel({ positions, commodities, credits, usedMargin }) {
const totalUnrealized = positions.reduce((sum, p) => {
const c = commodities.find(c => c.symbol === p.symbol);
if (!c) return sum;
const currentValue = c.price * p.totalUnits;
const entryValue = p.avgEntry * p.totalUnits;
const diff = p.direction === 'long' ? currentValue - entryValue : entryValue - currentValue;
return sum + diff;
}, 0);
const totalPositionValue = positions.reduce((sum, p) => {
const c = commodities.find(c => c.symbol === p.symbol);
return sum + (c ? c.price * p.totalUnits : 0);
}, 0);
const marginUtilization = credits > 0 ? ((usedMargin / credits) * 100) : 0;
return (
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)', overflow: 'hidden',
}}>
<div style={{
padding: '8px 12px', borderBottom: '1px solid var(--border)',
background: 'var(--surface-raised)',
fontFamily: 'var(--font-mono)', fontSize: '0.65rem', color: 'var(--muted)',
textTransform: 'uppercase', letterSpacing: '0.06em',
display: 'flex', justifyContent: 'space-between',
}}>
<span>Open Positions</span>
<span style={{ color: totalUnrealized >= 0 ? 'var(--green)' : 'var(--red)' }}>
{totalUnrealized >= 0 ? '▲' : '▼'} {Math.abs(totalUnrealized).toLocaleString()}
</span>
</div>
{/* Account summary */}
<div style={{ padding: 'var(--sp-3) var(--sp-4)' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--sp-2)', marginBottom: 'var(--sp-3)' }}>
<div style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)', padding: '8px 10px' }}>
<div style={{ fontSize: '0.55rem', color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '1px' }}>Balance</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.85rem', fontWeight: 700, color: 'var(--fg-bright)' }}>{credits.toLocaleString()}</div>
</div>
<div style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)', padding: '8px 10px' }}>
<div style={{ fontSize: '0.55rem', color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '1px' }}>Used Margin</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.85rem', fontWeight: 700, color: 'var(--cyan)' }}>{usedMargin.toLocaleString()}</div>
</div>
<div style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)', padding: '8px 10px' }}>
<div style={{ fontSize: '0.55rem', color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '1px' }}>Exposure</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.85rem', fontWeight: 700, color: 'var(--accent)' }}>{totalPositionValue.toLocaleString()}</div>
</div>
</div>
{/* Margin utilization bar */}
<div style={{ marginBottom: 'var(--sp-3)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.55rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)', marginBottom: '3px' }}>
<span>MARGIN UTILIZATION</span>
<span style={{ color: marginUtilization > 80 ? 'var(--red)' : marginUtilization > 50 ? 'var(--accent)' : 'var(--green)' }}>
{marginUtilization.toFixed(1)}%
</span>
</div>
<div style={{ height: '4px', background: 'var(--surface-raised)', borderRadius: '2px', overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: '2px',
width: `${Math.min(marginUtilization, 100)}%`,
background: marginUtilization > 80 ? 'var(--red)' : marginUtilization > 50 ? 'var(--accent)' : 'var(--green)',
transition: 'width 0.3s ease',
}} />
</div>
</div>
{/* Position rows */}
{positions.length === 0 && (
<div style={{ textAlign: 'center', padding: 'var(--sp-4)', color: 'var(--muted)', fontSize: '0.75rem' }}>
No open positions
</div>
)}
{positions.map((p, i) => {
const c = commodities.find(c => c.symbol === p.symbol);
if (!c) return null;
const currentVal = c.price * p.totalUnits;
const entryVal = p.avgEntry * p.totalUnits;
const pnl = p.direction === 'long' ? currentVal - entryVal : entryVal - currentVal;
const pnlPct = entryVal > 0 ? (pnl / p.margin) * 100 : 0;
const contract = CONTRACTS.find(ct => ct.symbol === p.symbol);
return (
<div key={i} style={{
padding: '6px 0', borderBottom: '1px solid var(--border)', fontSize: '0.72rem',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '3px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--sp-2)' }}>
<span style={{ fontWeight: 700, color: 'var(--fg-bright)' }}>{p.symbol}</span>
<span style={{
fontSize: '0.55rem', fontFamily: 'var(--font-mono)', fontWeight: 600,
color: p.direction === 'long' ? 'var(--green)' : 'var(--red)',
background: p.direction === 'long' ? 'var(--green-bg)' : 'var(--red-bg)',
padding: '1px 5px', borderRadius: 'var(--radius-pill)',
}}>
{p.direction === 'long' ? 'LONG' : 'SHORT'}
</span>
</div>
<span style={{
fontFamily: 'var(--font-mono)', fontWeight: 700,
color: pnl >= 0 ? 'var(--green)' : 'var(--red)',
}}>
{pnl >= 0 ? '+' : ''}{pnl.toLocaleString()}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontFamily: 'var(--font-mono)', fontSize: '0.62rem', color: 'var(--muted)' }}>
<span>{p.lots} lot{p.lots > 1 ? 's' : ''} ({p.totalUnits.toLocaleString()} u.) @ {p.avgEntry}</span>
<span>Margin: {p.margin.toLocaleString()}</span>
</div>
<div style={{ marginTop: '3px', height: '3px', background: 'var(--surface-raised)', borderRadius: '2px', overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: '2px',
width: `${Math.min(Math.abs(pnlPct), 100)}%`,
background: pnl >= 0 ? 'var(--green)' : 'var(--red)',
opacity: 0.6,
}} />
</div>
</div>
);
})}
</div>
</div>
);
}
/* ── Trade Feed ── */
function TradeFeed({ trades }) {
return (
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)', overflow: 'hidden',
}}>
<div style={{
padding: '8px 12px', borderBottom: '1px solid var(--border)',
background: 'var(--surface-raised)',
fontFamily: 'var(--font-mono)', fontSize: '0.65rem', color: 'var(--muted)',
textTransform: 'uppercase', letterSpacing: '0.06em',
}}>
Market Trades
</div>
<div style={{ maxHeight: '180px', overflowY: 'auto', overscrollBehavior: 'contain' }}>
{trades.map((t, i) => (
<div key={i} style={{
display: 'grid', gridTemplateColumns: '42px 1fr 70px 50px',
padding: '4px 12px', fontFamily: 'var(--font-mono)', fontSize: '0.65rem',
borderBottom: '1px solid var(--border)',
background: i < 2 ? (t.side === 'long' ? 'rgba(34,197,94,0.03)' : 'rgba(239,68,68,0.03)') : 'transparent',
}}>
<span style={{ color: 'var(--muted)' }}>{t.time}</span>
<span style={{ color: 'var(--fg-dim)' }}>{t.lots}× {t.symbol}</span>
<span style={{ color: 'var(--fg-bright)', textAlign: 'right' }}>{t.price.toLocaleString()}</span>
<span style={{ color: t.side === 'long' ? 'var(--green)' : 'var(--red)', textAlign: 'right', fontWeight: 600, fontSize: '0.58rem' }}>
{t.side === 'long' ? 'BID' : 'ASK'}
</span>
</div>
))}
</div>
</div>
);
}
/* ──────────────────────────────────────────────
Main MarketDemo component
────────────────────────────────────────────── */
function MarketDemo() {
const [credits, setCredits] = useState(250000);
const [selectedSymbol, setSelectedSymbol] = useState('VLD');
const [category, setCategory] = useState('all');
const [notifications, setNotifications] = useState([]);
/* Generate live commodity data */
const [commodities, setCommodities] = useState(() => {
return CONTRACTS.map(ct => {
const history = []; let p = ct.basePrice;
for (let i = 0; i < 60; i++) {
p = Math.max(ct.basePrice * 0.7, Math.min(ct.basePrice * 1.3, p + (Math.random() - 0.48) * ct.basePrice * 0.04));
history.push(Math.round(p * 100) / 100);
}
const price = history[history.length - 1];
const prev = history[history.length - 2];
const settlement = history[Math.floor(history.length / 2)];
const bestBid = Math.round((price - ct.basePrice * 0.005) * 100) / 100;
const bestAsk = Math.round((price + ct.basePrice * 0.005) * 100) / 100;
return {
...ct,
price, prevPrice: prev,
change: price - prev,
changePct: ((price - prev) / prev) * 100,
history, settlement, bestBid, bestAsk,
volume: Math.floor(Math.random() * 80000 + 5000),
openInterest: Math.floor(Math.random() * 200000 + 10000),
open: history[0],
high: Math.max(...history),
low: Math.min(...history),
supply: Math.floor(Math.random() * 50000 + 10000),
demand: Math.floor(Math.random() * 60000 + 8000),
};
});
});
/* Open positions */
const [positions, setPositions] = useState([
{ symbol: 'VLD', direction: 'long', lots: 5, totalUnits: 5000, avgEntry: 13.20, margin: 5280 },
{ symbol: 'TRI', direction: 'long', lots: 3, totalUnits: 15000, avgEntry: 4.80, margin: 2160 },
{ symbol: 'ARK', direction: 'short', lots: 2, totalUnits: 100, avgEntry: 635, margin: 25400 },
]);
/* Order book for selected */
const selected = commodities.find(c => c.symbol === selectedSymbol) || commodities[0];
const selectedContract = CONTRACTS.find(c => c.symbol === selectedSymbol) || CONTRACTS[0];
const orderBook = useMemo(() => {
const p = selected.price;
const bids = []; const asks = [];
let bidCum = 0; let askCum = 0;
for (let i = 0; i < 10; i++) {
const bv = Math.floor(Math.random() * 12000 + 500); bidCum += bv;
bids.push({ price: Math.max(0.01, Math.round((p - (i + 1) * selectedContract.tickSize * 3) * 100) / 100), volume: bv, cumulative: bidCum });
const av = Math.floor(Math.random() * 12000 + 500); askCum += av;
asks.push({ price: Math.round((p + (i + 1) * selectedContract.tickSize * 3) * 100) / 100, volume: av, cumulative: askCum });
}
return { bids, asks };
}, [selected.price, selectedSymbol, selectedContract.tickSize]);
const usedMargin = positions.reduce((s, p) => s + p.margin, 0);
/* Price chart data */
const chartData = useMemo(() => {
return selected.history.map((c, i) => {
const noise = selected.price * 0.01;
return {
open: selected.history[Math.max(0, i - 1)] || c,
high: c + Math.random() * noise * 2,
low: Math.max(0.01, c - Math.random() * noise * 2),
close: c,
volume: Math.floor(Math.random() * 5000 + 500),
};
});
}, [selected]);
/* Trade feed */
const [tradeFeed, setTradeFeed] = useState(() => {
const feed = [];
const syms = CONTRACTS.map(c => c.symbol);
for (let i = 0; i < 20; i++) {
const sym = syms[Math.floor(Math.random() * syms.length)];
const ct = CONTRACTS.find(c => c.symbol === sym);
const c = commodities.find(c => c.symbol === sym);
feed.push({
time: `${14}:${String(22 + i).padStart(2, '0')}`,
symbol: sym,
lots: Math.floor(Math.random() * 10 + 1),
price: c ? Math.round(c.price * 100) / 100 : ct.basePrice,
side: Math.random() > 0.5 ? 'long' : 'short',
});
}
return feed.reverse();
});
/* Live price ticks */
useEffect(() => {
const interval = setInterval(() => {
setCommodities(prev => prev.map(c => {
const ct = CONTRACTS.find(x => x.symbol === c.symbol);
const delta = (Math.random() - 0.48) * c.price * 0.006;
const newPrice = Math.max(0.01, Math.round((c.price + delta) * 100) / 100);
const history = [...c.history.slice(1), newPrice];
const change = newPrice - c.prevPrice;
const spread = ct.basePrice * 0.005;
return {
...c, price: newPrice, history,
volume: c.volume + Math.floor(Math.random() * 300),
change, changePct: c.prevPrice > 0 ? (change / c.prevPrice) * 100 : 0,
high: Math.max(c.high, newPrice), low: Math.min(c.low, newPrice),
bestBid: Math.round((newPrice - spread) * 100) / 100,
bestAsk: Math.round((newPrice + spread) * 100) / 100,
openInterest: c.openInterest + Math.floor(Math.random() * 200 - 80),
supply: c.supply + Math.floor(Math.random() * 200 - 100),
demand: c.demand + Math.floor(Math.random() * 200 - 80),
};
}));
}, 2500);
return () => clearInterval(interval);
}, []);
/* New trades in feed */
useEffect(() => {
const interval = setInterval(() => {
const syms = CONTRACTS.map(c => c.symbol);
const sym = syms[Math.floor(Math.random() * syms.length)];
const c = commodities.find(c => c.symbol === sym);
const ct = CONTRACTS.find(c => c.symbol === sym);
const now = new Date();
setTradeFeed(prev => [{
time: `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}`,
symbol: sym,
lots: Math.floor(Math.random() * 15 + 1),
price: c ? c.price : ct.basePrice,
side: Math.random() > 0.5 ? 'long' : 'short',
}, ...prev].slice(0, 40));
}, 3000);
return () => clearInterval(interval);
}, [commodities]);
const addNotif = (msg, color) => {
const id = Date.now();
setNotifications(prev => [...prev, { id, msg, color }]);
setTimeout(() => setNotifications(prev => prev.filter(n => n.id !== id)), 3500);
};
const handleTrade = (direction, symbol, price, lots, totalUnits, marginRequired, commission) => {
/* Deduct commission + margin from credits */
setCredits(prev => prev - commission - marginRequired);
setPositions(prev => {
const existing = prev.find(p => p.symbol === symbol && p.direction === direction);
if (existing) {
/* Merge into existing position — weighted average entry */
const newUnits = existing.totalUnits + totalUnits;
const newAvg = Math.round(((existing.avgEntry * existing.totalUnits) + (price * totalUnits)) / newUnits * 100) / 100;
const newMargin = existing.margin + marginRequired;
return prev.map(p => p.symbol === symbol && p.direction === direction
? { ...p, lots: p.lots + lots, totalUnits: newUnits, avgEntry: newAvg, margin: newMargin }
: p);
}
return [...prev, { symbol, direction, lots, totalUnits, avgEntry: price, margin: marginRequired }];
});
addNotif(
`${direction === 'long' ? 'LONG' : 'SHORT'} ${lots}× ${symbol} (${totalUnits.toLocaleString()} u.) @ ₢${price}`,
direction === 'long' ? 'var(--green)' : 'var(--red)'
);
const now = new Date();
setTradeFeed(prev => [{
time: `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}`,
symbol, lots, price, side: direction,
}, ...prev].slice(0, 40));
};
/* Market stats */
const totalVolume = commodities.reduce((s, c) => s + c.volume, 0);
const totalOI = commodities.reduce((s, c) => s + c.openInterest, 0);
const advancers = commodities.filter(c => c.change >= 0).length;
const decliners = commodities.length - advancers;
const tickerItems = commodities.slice(0, 12).map(c => ({
symbol: c.symbol, price: c.price, change: c.change, changePct: c.changePct,
}));
const spread = selectedContract ? Math.round((selected.bestAsk - selected.bestBid) * 100) / 100 : 0;
const spreadPct = selected.bestBid > 0 ? (spread / selected.bestBid) * 100 : 0;
const midPrice = Math.round(((selected.bestBid + selected.bestAsk) / 2) * 100) / 100;
return (
<div className="content-inner" style={{ maxWidth: '1260px' }}>
<a href="#overview" style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', fontSize: '0.75rem', fontFamily: 'var(--font-mono)', color: 'var(--muted)', textDecoration: 'none', marginBottom: 'var(--sp-2)', transition: 'color 0.15s' }} onMouseEnter={e => e.currentTarget.style.color='var(--fg-bright)'} onMouseLeave={e => e.currentTarget.style.color='var(--muted)'}> Back to Docs</a>
{/* Ticker */}
<TickerTape items={tickerItems} />
{/* Header bar */}
<div style={{
display: 'flex', alignItems: 'center', gap: 'var(--sp-3)',
padding: 'var(--sp-3) 0', fontFamily: 'var(--font-mono)', fontSize: '0.7rem',
borderBottom: '1px solid var(--border)', marginBottom: 'var(--sp-3)',
flexWrap: 'wrap',
}}>
<span style={{ fontWeight: 700, color: 'var(--fg-bright)', fontSize: '0.85rem', letterSpacing: '0.03em' }}>COMMODITIES EXCHANGE</span>
<div style={{ width: 1, height: 14, background: 'var(--border)' }} />
<span style={{ color: 'var(--muted)' }}>SESSION</span>
<span style={{ color: 'var(--green)', fontWeight: 600 }}>OPEN</span>
<div style={{ width: 1, height: 14, background: 'var(--border)' }} />
<span style={{ color: 'var(--green)' }}>{advancers} </span>
<span style={{ color: 'var(--red)' }}>{decliners} </span>
<div style={{ width: 1, height: 14, background: 'var(--border)' }} />
<span style={{ color: 'var(--muted)' }}>TOTAL VOL</span>
<span style={{ color: 'var(--fg-bright)' }}>{(totalVolume / 1e6).toFixed(2)}M</span>
<div style={{ width: 1, height: 14, background: 'var(--border)' }} />
<span style={{ color: 'var(--muted)' }}>OPEN INT.</span>
<span style={{ color: 'var(--cyan)' }}>{(totalOI / 1e6).toFixed(1)}M</span>
<div style={{ width: 1, height: 14, background: 'var(--border)' }} />
<span style={{ color: 'var(--muted)' }}>ACCOUNT ( = ISK)</span>
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>{credits.toLocaleString()}</span>
</div>
{/* Category tabs */}
<CategoryTabs active={category} onChange={setCategory} />
{/* Notifications */}
<div style={{ position: 'fixed', top: 'var(--sp-4)', right: 'var(--sp-4)', zIndex: 1000, display: 'flex', flexDirection: 'column', gap: 'var(--sp-2)' }}>
{notifications.map(n => (
<div key={n.id} style={{
background: 'var(--surface)', border: `1px solid ${n.color}40`,
borderRadius: 'var(--radius-md)', padding: 'var(--sp-2) var(--sp-4)',
fontSize: '0.72rem', color: n.color, fontFamily: 'var(--font-mono)',
boxShadow: 'var(--shadow-md)',
}}>
{n.msg}
</div>
))}
</div>
{/* Main grid: Left (board + chart + feed) | Right (specs + depth + order + positions) */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 380px', gap: 'var(--sp-4)', alignItems: 'start' }}>
{/* Left column */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--sp-4)' }}>
{/* Contract board */}
<ContractBoard commodities={commodities} selected={selectedSymbol} onSelect={setSelectedSymbol} category={category} />
{/* Price chart */}
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)', overflow: 'hidden',
}}>
<div style={{
padding: '8px 14px', borderBottom: '1px solid var(--border)',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 'var(--sp-2)' }}>
<span style={{ fontWeight: 700, color: 'var(--accent)', fontSize: '0.9rem', fontFamily: 'var(--font-mono)' }}>{selected.symbol}</span>
<span style={{ fontSize: '0.7rem', color: 'var(--fg-dim)' }}>{selected.name}</span>
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 'var(--sp-3)' }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.65rem', color: 'var(--muted)' }}>
O:{selected.open} H:{selected.high} L:{selected.low}
</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '1.1rem', fontWeight: 700, color: 'var(--fg-bright)' }}>
{selected.price.toLocaleString()}
</span>
<span style={{
fontFamily: 'var(--font-mono)', fontSize: '0.7rem', fontWeight: 600,
color: selected.change >= 0 ? 'var(--green)' : 'var(--red)',
}}>
{selected.change >= 0 ? '+' : ''}{selected.change.toFixed(2)} ({selected.changePct >= 0 ? '+' : ''}{selected.changePct.toFixed(2)}%)
</span>
</div>
</div>
<div style={{ padding: 'var(--sp-3)' }}>
<PriceChart data={chartData} symbol={selectedSymbol} />
</div>
</div>
{/* Trade feed */}
<TradeFeed trades={tradeFeed} />
</div>
{/* Right column */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--sp-4)', position: 'sticky', top: 'var(--sp-4)' }}>
{/* Contract spec */}
<ContractSpec contract={selectedContract} commodity={selected} />
{/* Depth chart */}
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)', overflow: 'hidden',
padding: 'var(--sp-3)',
}}>
<div style={{
fontFamily: 'var(--font-mono)', fontSize: '0.6rem', color: 'var(--muted)',
textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 'var(--sp-2)',
}}>
Depth of Market
</div>
<DepthChart bids={orderBook.bids} asks={orderBook.asks} midPrice={midPrice} />
</div>
{/* Order book */}
<OrderBook
bids={orderBook.bids} asks={orderBook.asks}
spread={spread} spreadPct={spreadPct}
/>
{/* Order form */}
<OrderForm
contract={selectedContract}
commodity={selected}
credits={credits}
positions={positions}
onTrade={handleTrade}
/>
{/* Positions */}
<PositionsPanel
positions={positions}
commodities={commodities}
credits={credits}
usedMargin={usedMargin}
/>
</div>
</div>
</div>
);
}
window.GDD.MarketDemo = MarketDemo;