// Tools — 3 modes, modernised: // 1) Cost sheet — animated grand total, segmented gold breakdown bar, // line-items with per-row donut shares. // 2) EMI calc — central principal-vs-interest donut with the EMI // number BIG inside; gold-fill range sliders; year-by-year // amortization mini-bars; live ratio stats. // 3) Compare — side-by-side cards with FLOOR PLAN images, vertical // "VS" divider, and per-metric winner indicators. // // All literal sizes/paddings/gaps/radii bumped ~+5% relative to the prior // version. Theme: --tile gradient + --on-tile for headline cards; // --gold-deep for mono labels. const TOOLS_KEYS_ID = 'uni-tools-keys'; function ensureToolsKeys() { if (typeof document === 'undefined') return; if (document.getElementById(TOOLS_KEYS_ID)) return; const s = document.createElement('style'); s.id = TOOLS_KEYS_ID; s.textContent = ` /* Custom range slider — gold gradient track + 32px gold thumb. */ input.uni-range { -webkit-appearance: none; appearance: none; width: 100%; background: transparent; height: 32px; cursor: pointer; outline: none; } input.uni-range::-webkit-slider-runnable-track { height: 8px; border-radius: 100px; background: var(--uni-track, var(--line)); box-shadow: inset 0 1px 2px rgba(50,32,12,0.10); } input.uni-range::-moz-range-track { height: 8px; border-radius: 100px; background: var(--uni-track, var(--line)); box-shadow: inset 0 1px 2px rgba(50,32,12,0.10); } input.uni-range::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 32px; height: 32px; border-radius: 50%; margin-top: -12px; background: radial-gradient(circle at 35% 30%, #f3e0b1 0%, #c9a05e 55%, #8b6f3d 100%); border: 2px solid #faf6e8; box-shadow: 0 6px 14px rgba(50,32,12,0.32), 0 2px 4px rgba(50,32,12,0.20), inset 0 1px 0 rgba(255,246,224,0.55); cursor: grab; transition: transform 180ms cubic-bezier(0.22,1,0.36,1); } input.uni-range:active::-webkit-slider-thumb { transform: scale(1.12); cursor: grabbing; } input.uni-range::-moz-range-thumb { width: 32px; height: 32px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #f3e0b1 0%, #c9a05e 55%, #8b6f3d 100%); border: 2px solid #faf6e8; box-shadow: 0 6px 14px rgba(50,32,12,0.32), 0 2px 4px rgba(50,32,12,0.20), inset 0 1px 0 rgba(255,246,224,0.55); cursor: grab; } /* Stacked-bar segment hover lift */ .uni-seg { transition: flex-grow 280ms cubic-bezier(0.22,1,0.36,1), transform 240ms cubic-bezier(0.22,1,0.36,1), filter 240ms; } .uni-seg:hover { transform: translateY(-3px); filter: brightness(1.10); } /* Amortization year hover */ .uni-amrt { transition: transform 200ms, filter 200ms; } .uni-amrt:hover { transform: translateY(-2px); filter: brightness(1.08); } `; document.head.appendChild(s); } // Live canvas-height density: 0 on the 16:10 Tab S7 (H=1600), 1 on iPad Pro 4:3 // (H=1920). Lets each screen grow figures + breathing room so content fills the // taller canvas and reads large on iPad, while leaving 16:10 mathematically // untouched (dens=0 → every interpolated size collapses to its 16:10 value). function useToolsDens() { const read = () => { const H = (typeof window !== 'undefined' && window.UNIVERSE_CANVAS && window.UNIVERSE_CANVAS.H) || 1600; return Math.max(0, Math.min(1, (H - 1600) / 320)); }; const [d, setD] = React.useState(read); React.useEffect(() => { const on = () => setD(read()); on(); window.addEventListener('resize', on); window.addEventListener('orientationchange', on); if (window.visualViewport) window.visualViewport.addEventListener('resize', on); return () => { window.removeEventListener('resize', on); window.removeEventListener('orientationchange', on); if (window.visualViewport) window.visualViewport.removeEventListener('resize', on); }; }, []); return d; } // interpolate a literal from its 16:10 value (a) to its iPad value (b) const dlerp = (d, a, b) => Math.round((a + (b - a) * d) * 100) / 100; function Tools() { ensureToolsKeys(); const dens = useToolsDens(); const [mode, setMode] = React.useState('cost'); // 'cost' | 'emi' | 'compare' return (
{[ { k:'cost', label:'Cost Sheet' }, { k:'emi', label:'EMI Calculator' }, { k:'compare', label:'Compare' }, ].map(m => ( ))}
{mode === 'cost' && } {mode === 'emi' && } {mode === 'compare' && }
); } // ============================================================================ // COST SHEET — animated total + segmented breakdown bar + line items w/ donuts // ============================================================================ function CostSheet({ dens = 0 }) { const d = dens; const [code, setCode] = React.useState(TYPOLOGIES[0].code); const [hoverSeg, setHoverSeg] = React.useState(null); const ty = TYPOLOGIES.find(x => x.code === code); const base = ty.price; const gst = Math.round(base * 0.05); const stamp = Math.round(base * 0.049); const reg = Math.round(base * 0.01); const club = 350000; const car = 600000; const corpus = 250000; const legal = 35000; const total = base + gst + stamp + reg + club + car + corpus + legal; // Gold tonal ramp (light → deep) used both in the bar and the row donuts. const segments = [ { k:'BASE', label:'Base price', v: base, color:'#a98e5d' }, { k:'GST', label:'GST 5%', v: gst, color:'#c9a05e' }, { k:'STAMP', label:'Stamp 4.9%', v: stamp, color:'#b08a3f' }, { k:'REG', label:'Registration 1%', v: reg, color:'#d8b573' }, { k:'CLUB', label:'Club', v: club, color:'#8b6f3d' }, { k:'PARKING', label:'Parking ×2', v: car, color:'#6f5829' }, { k:'CORPUS', label:'Corpus', v: corpus,color:'#9c7d4a' }, { k:'LEGAL', label:'Legal', v: legal, color:'#7a6233' }, ]; const rows = [ { k:'Base price (carpet × rate)', v: base, note: ty.sqft.toLocaleString()+' sq ft', color:'#a98e5d' }, { k:'GST (5%, under-construction)', v: gst, color:'#c9a05e' }, { k:'Stamp duty (4.9%)', v: stamp, color:'#b08a3f' }, { k:'Registration (1%)', v: reg, color:'#d8b573' }, { k:'Club membership (1×)', v: club, note:'lifetime', color:'#8b6f3d' }, { k:'Covered parking (2 nos.)', v: car, note:'2 cars', color:'#6f5829' }, { k:'Society corpus (1×)', v: corpus, color:'#9c7d4a' }, { k:'Legal & documentation', v: legal, color:'#7a6233' }, ]; return (
{/* LEFT — selector + animated total + segmented breakdown */}
SELECT TYPOLOGY
{/* big animated total card — grows to fill the column; total sits at the top, the breakdown bar sinks to the card's base so the card reads full and intentional at every canvas height */}
ALL-INCLUSIVE TOTAL
L
{ty.name} · ₹ {Math.round(base/ty.sqft).toLocaleString()}/sqft base
{/* indicative construction-linked payment plan — sits a fixed, comfortable distance under the total so the card's free space collects as ONE band at the base (under the breakdown) rather than two awkward mid-card voids on the taller iPad canvas */}
INDICATIVE PAYMENT PLAN
{[ { k:'ON BOOKING', pct:0.10 }, { k:'DURING CONSTRUCTION', pct:0.75 }, { k:'ON POSSESSION', pct:0.15 }, ].map((m, i) => (
{Math.round(m.pct*100)}%
{formatINR(Math.round(total*m.pct), {decimals:1})}
{m.k}
))}
{/* segmented breakdown bar — anchored to the bottom of the card */}
BREAKDOWN
{segments.map((s, i) => { const pct = s.v / total; const isHover = hoverSeg === i; return (
setHoverSeg(i)} onMouseLeave={()=>setHoverSeg(null)} style={{ flex: pct, flexGrow: isHover ? pct * 1.05 : pct, background: s.color, borderRight: i < segments.length - 1 ? '1px solid rgba(0,0,0,0.18)' : 'none', position:'relative', cursor:'default', }}> {isHover && (
{s.label} {(pct*100).toFixed(1)}%
)}
); })}
{/* legend dots */}
{segments.map((s, i) => (
{s.k}
))}
Figures are illustrative for sales discussion. Actual schedule of payment, GST and registration charges will be reflected in the booking application form per RERA disclosures.
{/* RIGHT — line items with per-row donut % shares (rows distribute to fill height) */}
LINE-ITEM BREAKDOWN
{rows.map((r, i) => { const pct = r.v / total; const ds = dlerp(d,50,60); return (
{/* mini donut */}
{(pct*100).toFixed(0)}%
{r.k}
{r.note &&
{r.note}
}
{formatINR(r.v, {decimals:2})}
); })}
Total payable
{formatINR(total, {decimals:2})}
); } // ============================================================================ // EMI — central principal-vs-interest donut (with EMI BIG inside) + custom // gold sliders + amortization mini-bars + ratio stats // ============================================================================ function EMI({ dens = 0 }) { const d = dens; const [code, setCode] = React.useState(TYPOLOGIES[0].code); const ty = TYPOLOGIES.find(x => x.code === code); const [downPct, setDownPct] = React.useState(20); const [rate, setRate] = React.useState(8.5); const [years, setYears] = React.useState(20); const [hoverYr, setHoverYr] = React.useState(null); const base = ty.price; const down = base * downPct / 100; const loan = base - down; const r = rate/100/12; const n = years * 12; const emi = loan * r * Math.pow(1+r,n) / (Math.pow(1+r,n) - 1); const totalInt = emi * n - loan; const totalPay = loan + totalInt; const monthlyIncome = 500000; // assumed for ratio const ratioPct = (emi / monthlyIncome) * 100; // Year-by-year amortization const yearly = React.useMemo(() => { const arr = []; let bal = loan; for (let y = 1; y <= years; y++) { let pPaid = 0, iPaid = 0; for (let m = 0; m < 12; m++) { const ip = bal * r; const pp = emi - ip; bal = Math.max(0, bal - pp); pPaid += pp; iPaid += ip; } arr.push({ y, principal: pPaid, interest: iPaid, balance: bal }); } return arr; }, [loan, r, emi, years]); const maxYearTotal = Math.max(...yearly.map(d => d.principal + d.interest), 1); // Donut geometry — central card (grows on the taller iPad canvas so the card // never reads hollow) const donutSize = dlerp(d, 380, 560); const donutStroke = dlerp(d, 38, 52); const donutR = (donutSize - donutStroke) / 2; const donutC = 2 * Math.PI * donutR; const principalShare = loan / totalPay; const interestShare = totalInt / totalPay; // Helper for gold-fill range track const trackBg = (pct) => `linear-gradient(90deg, var(--gold-deep) 0%, var(--gold) ${pct}%, var(--line) ${pct}%, var(--line) 100%)`; return (
{/* LEFT — typology + sliders + amortization */}
TYPOLOGY
{/* amortization mini-chart — grows to fill the column on tall canvases */}
AMORTIZATION · YEAR-WISE
{hoverYr !== null && yearly[hoverYr] && (
Y{yearly[hoverYr].y} · P {formatINR(Math.round(yearly[hoverYr].principal),{decimals:1})} · I {formatINR(Math.round(yearly[hoverYr].interest),{decimals:1})}
)}
{yearly.map((yr, i) => { const tot = yr.principal + yr.interest; const colPct = (tot / maxYearTotal) * 100; // column height as % of chart const pPct = (yr.principal / tot) * 100; // principal share within column const isHover = hoverYr === i; return (
setHoverYr(i)} onMouseLeave={()=>setHoverYr(null)} style={{ flex:1, display:'flex', flexDirection:'column', justifyContent:'flex-end', height:`${colPct}%`, cursor:'pointer', filter: isHover ? 'brightness(1.10)' : 'none', }}>
); })}
Y1Y{Math.ceil(years/2)}Y{years}
PRINCIPAL INTEREST
{/* RIGHT — donut card grows to fill; stats + context anchor below */}
MONTHLY EMI · PRINCIPAL VS INTEREST
{/* DONUT with EMI in centre — vertically centred in the card's free space */}
{/* principal arc — gold soft */} {/* interest arc — gold deep */}
EMI / MONTH
{years*12} months · {rate}% p.a.
{/* donut legend */}
PRINCIPAL {Math.round(principalShare*100)}%
INTEREST {Math.round(interestShare*100)}%
{/* stats row */}
40}/>
{/* down-payment context */}
DOWN PAYMENT · {downPct}%
{formatINR(Math.round(down),{decimals:2})}
UNIT PRICE
{formatINR(base,{decimals:2})}
); } // ============================================================================ // COMPARE — side-by-side cards with floor plan IMAGES + VS divider + winner row // ============================================================================ function Compare({ dens = 0 }) { const d = dens; const [a, setA] = React.useState(TYPOLOGIES[0].code); const [b, setB] = React.useState(TYPOLOGIES[4].code); const A = TYPOLOGIES.find(x => x.code === a); const B = TYPOLOGIES.find(x => x.code === b); // ---- winner logic per metric ------------------------------------------- // For carpet: bigger wins ("more spacious"). Compact-flag for the smaller. // For base price: lower wins. // For per-sqft: lower wins. // Character is descriptive (the typology tag), not a contest. const metrics = [ { k: 'CARPET', av: A.sqft, bv: B.sqft, fmt: v => v.toLocaleString() + ' sqft', winner: A.sqft === B.sqft ? null : (A.sqft > B.sqft ? 'A' : 'B'), winnerNote: 'more spacious', loserNote: 'more compact', }, { k: 'BASE PRICE', av: A.price, bv: B.price, fmt: v => formatINR(v, {decimals:2}), winner: A.price === B.price ? null : (A.price < B.price ? 'A' : 'B'), winnerNote: 'better value', loserNote: 'higher ticket', }, { k: 'PER SQFT', av: Math.round(A.price/A.sqft), bv: Math.round(B.price/B.sqft), fmt: v => '₹ ' + v.toLocaleString() + '/sqft', winner: (A.price/A.sqft) === (B.price/B.sqft) ? null : ((A.price/A.sqft) < (B.price/B.sqft) ? 'A' : 'B'), winnerNote: 'lower /sqft', loserNote: 'higher /sqft', }, { k: 'CHARACTER', av: A.tag, bv: B.tag, fmt: v => v, winner: null, // descriptive, not a contest winnerNote: '', loserNote: '', }, ]; return (
{/* CARDS row */}
setA(c)}/> setB(c)}/>
{/* WINNER ROW */}
{metrics.map((m, i) => ( ))}
); } function CompareCard({ letter, ty, setTy, dens = 0 }) { const d = dens; const isA = letter === 'A'; // available blocks → typologies for the pill-list filter const blocks = Array.from(new Set(TYPOLOGIES.map(x => x.pair))); const [block, setBlock] = React.useState(ty.pair); const inBlock = TYPOLOGIES.filter(x => x.pair === block); // if currently selected ty isn't in the new block, hop to first match React.useEffect(() => { if (!inBlock.find(x => x.code === ty.code)) { if (inBlock[0]) setTy(inBlock[0].code); } // eslint-disable-next-line }, [block]); const planSrc = ty.plan; // already a full assets/plans/*.jpg path return (
{/* header — option label + block pills */}
OPTION {letter}
{blocks.map(blk => ( ))}
{/* unit-within-block dropdown */} {/* LARGE FLOOR PLAN IMAGE — zoomable */} {/* metadata row */}
{ty.name}
); } function VsDivider() { return (
vs
); } function Mini({ k, v, dens = 0 }) { const d = dens; return (
{k}
{v}
); } // === ZoomablePlan =========================================================== // Floor plan image with full pinch / drag / wheel / double-tap-reset gestures // PLUS visible + / − / RESET buttons. Mirrors the residences-screen pattern // so the gestures feel consistent across the app. function ZoomablePlan({ src, alt, block }) { const [zoom, setZoom] = React.useState(1); const [pan, setPan] = React.useState({x:0, y:0}); const [gestureActive, setGestureActive] = React.useState(false); const containerRef = React.useRef(null); const gestureRef = React.useRef({ mode:null, startD:0, startZoom:1, startPanX:0, startPanY:0, startMidX:0, startMidY:0, lastTapAt:0, lastTapX:0, lastTapY:0, dragStartX:0, dragStartY:0, }); const MIN_Z = 1, MAX_Z = 5, STEP = 0.6; // Reset when src changes (user picked a new typology) React.useEffect(()=>{ setZoom(1); setPan({x:0, y:0}); }, [src]); // Clamp pan so the plan stays mostly inside the frame const clampPan = (p, z) => { const el = containerRef.current; if (!el) return p; const r = el.getBoundingClientRect(); const maxX = Math.max(0, ((z - 1) / 2) * r.width) + 40; const maxY = Math.max(0, ((z - 1) / 2) * r.height) + 40; return { x: Math.max(-maxX, Math.min(maxX, p.x)), y: Math.max(-maxY, Math.min(maxY, p.y)), }; }; // Zoom toward a point (cx, cy in container-local coords) const zoomAt = (newZoom, cx, cy) => { const z = Math.max(MIN_Z, Math.min(MAX_Z, newZoom)); const el = containerRef.current; if (!el) { setZoom(z); return; } const r = el.getBoundingClientRect(); const ax = cx - r.width/2; const ay = cy - r.height/2; const k = z / zoom; setZoom(z); if (z <= MIN_Z) setPan({x:0, y:0}); else setPan(clampPan({ x: ax - (ax - pan.x) * k, y: ay - (ay - pan.y) * k }, z)); }; // ----- TOUCH ----- (pinch + 1-finger pan + tap-to-zoom + double-tap-reset) const handleTouchStart = (ev) => { const g = gestureRef.current; const touches = ev.touches; const el = containerRef.current; const rect = el ? el.getBoundingClientRect() : { left:0, top:0 }; if (touches.length >= 2) { const [a, b] = [touches[0], touches[1]]; const dx = b.clientX - a.clientX, dy = b.clientY - a.clientY; g.mode = 'pinch'; setGestureActive(true); g.startD = Math.hypot(dx, dy) || 1; g.startZoom = zoom; g.startPanX = pan.x; g.startPanY = pan.y; g.startMidX = (a.clientX + b.clientX) / 2 - rect.left; g.startMidY = (a.clientY + b.clientY) / 2 - rect.top; } else if (touches.length === 1) { const t0 = touches[0]; const tx = t0.clientX, ty = t0.clientY; g.dragStartX = tx; g.dragStartY = ty; g.startPanX = pan.x; g.startPanY = pan.y; // Track tap timing for double-tap detection (handled in touchend) if (zoom > MIN_Z) { g.mode = 'pan-touch'; setGestureActive(true); } else { g.mode = 'tap-pending'; } } }; const handleTouchMove = (ev) => { const g = gestureRef.current; if (!g.mode) return; if (ev.cancelable) ev.preventDefault(); const touches = ev.touches; if (g.mode === 'pinch' && touches.length >= 2) { const [a, b] = [touches[0], touches[1]]; const dx = b.clientX - a.clientX, dy = b.clientY - a.clientY; const newD = Math.hypot(dx, dy) || 1; const newZoom = Math.max(MIN_Z, Math.min(MAX_Z, g.startZoom * (newD / g.startD))); const el = containerRef.current; const rect = el ? el.getBoundingClientRect() : { left:0, top:0 }; const cx = (a.clientX + b.clientX) / 2 - rect.left; const cy = (a.clientY + b.clientY) / 2 - rect.top; const offX = (cx - g.startMidX) + g.startPanX * (newZoom / g.startZoom); const offY = (cy - g.startMidY) + g.startPanY * (newZoom / g.startZoom); setZoom(newZoom); setPan(clampPan({ x: offX, y: offY }, newZoom)); } else if (g.mode === 'pan-touch' && touches.length === 1) { const t0 = touches[0]; const dx = t0.clientX - g.dragStartX; const dy = t0.clientY - g.dragStartY; setPan(clampPan({ x: g.startPanX + dx, y: g.startPanY + dy }, zoom)); } else if (g.mode === 'tap-pending' && touches.length === 1) { // If finger has moved meaningfully, cancel the pending tap const t0 = touches[0]; if (Math.hypot(t0.clientX - g.dragStartX, t0.clientY - g.dragStartY) > 12) { g.mode = null; } } }; const handleTouchEnd = (ev) => { const g = gestureRef.current; // Tap-to-zoom-in (when zoom===1) and double-tap-reset (when zoomed) if (g.mode === 'tap-pending' && ev.changedTouches && ev.changedTouches.length) { const t = ev.changedTouches[0]; const el = containerRef.current; const rect = el ? el.getBoundingClientRect() : { left:0, top:0 }; const localX = t.clientX - rect.left; const localY = t.clientY - rect.top; const now = performance.now(); const isDouble = (now - g.lastTapAt < 320 && Math.hypot(t.clientX - g.lastTapX, t.clientY - g.lastTapY) < 40); if (isDouble) { // Double-tap → reset setZoom(1); setPan({x:0,y:0}); g.lastTapAt = 0; } else { // Single tap on plan when zoom===1 → zoom in 2× at tap point if (zoom <= MIN_Z) zoomAt(2, localX, localY); g.lastTapAt = now; g.lastTapX = t.clientX; g.lastTapY = t.clientY; } } if (ev.touches.length === 0) { g.mode = null; setGestureActive(false); } else if (ev.touches.length === 1 && g.mode === 'pinch') { // Transition pinch → 1-finger pan const t0 = ev.touches[0]; g.mode = 'pan-touch'; g.dragStartX = t0.clientX; g.dragStartY = t0.clientY; g.startPanX = pan.x; g.startPanY = pan.y; } }; // ----- MOUSE ----- (drag-pan when zoomed; click to zoom-in when 1×) const handleMouseDown = (ev) => { const g = gestureRef.current; g.dragStartX = ev.clientX; g.dragStartY = ev.clientY; g.startPanX = pan.x; g.startPanY = pan.y; if (zoom > MIN_Z) { g.mode = 'pan-mouse'; setGestureActive(true); } else { g.mode = 'mouse-tap-pending'; } }; const handleMouseMove = (ev) => { const g = gestureRef.current; if (g.mode === 'pan-mouse') { const dx = ev.clientX - g.dragStartX; const dy = ev.clientY - g.dragStartY; setPan(clampPan({ x: g.startPanX + dx, y: g.startPanY + dy }, zoom)); } else if (g.mode === 'mouse-tap-pending') { if (Math.hypot(ev.clientX - g.dragStartX, ev.clientY - g.dragStartY) > 6) { g.mode = null; } } }; const handleMouseUp = (ev) => { const g = gestureRef.current; if (g.mode === 'mouse-tap-pending' && ev) { const el = containerRef.current; const rect = el ? el.getBoundingClientRect() : { left:0, top:0 }; zoomAt(2, ev.clientX - rect.left, ev.clientY - rect.top); } g.mode = null; setGestureActive(false); }; // ----- WHEEL + non-passive touchmove ----- (cursor-anchored zoom) React.useEffect(() => { const el = containerRef.current; if (!el) return; const onWheel = (ev) => { ev.preventDefault(); const rect = el.getBoundingClientRect(); const delta = ev.deltaY > 0 ? -0.18 : 0.18; const newZoom = Math.max(MIN_Z, Math.min(MAX_Z, zoom * (1 + delta))); zoomAt(newZoom, ev.clientX - rect.left, ev.clientY - rect.top); }; const onTM = (ev) => handleTouchMove(ev); el.addEventListener('wheel', onWheel, { passive: false }); el.addEventListener('touchmove', onTM, { passive: false }); return () => { el.removeEventListener('wheel', onWheel); el.removeEventListener('touchmove', onTM); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [zoom, pan]); const stepZoom = (dir) => { const el = containerRef.current; if (!el) return; const r = el.getBoundingClientRect(); zoomAt(zoom + dir * STEP, r.width/2, r.height/2); }; return (
MIN_Z ? (gestureActive ? 'grabbing' : 'grab') : 'zoom-in', touchAction: 'none', userSelect:'none', }} > {alt} {/* corner badge — block tag (fades when zoomed so it doesn't obstruct) */}
MIN_Z ? 0.0 : 1, transition:'opacity 200ms ease', pointerEvents:'none', }}>BLOCK {block}
{/* zoom controls — bottom-right, large enough for tablet taps */}
{Math.round(zoom*100)}%
{/* hint pill — bottom-left */} {zoom <= MIN_Z && (
Tap or pinch to zoom
)}
); } const zoomBtnSty = { width:50, height:50, borderRadius:'50%', background:'rgba(20,16,11,0.88)', border:'1px solid rgba(232,215,168,0.32)', color:'rgba(232,215,168,0.98)', fontSize:22, fontWeight:600, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', boxShadow:'0 4px 14px rgba(20,16,11,0.30)', WebkitTapHighlightColor:'transparent', }; function WinnerCell({ m, dens = 0 }) { const d = dens; const tied = m.winner === null; const aWin = m.winner === 'A'; return (
{m.k}
{/* A side */}
{m.fmt(m.av)}
{!tied && (
{aWin ? m.winnerNote.toUpperCase() : m.loserNote.toUpperCase()}
)}
{/* arrow / equal */}
{tied ? '=' : (aWin ? '<' : '>')}
{/* B side */}
{m.fmt(m.bv)}
{!tied && (
{!aWin ? m.winnerNote.toUpperCase() : m.loserNote.toUpperCase()}
)}
); } // ============================================================================ // SHARED — slider + stat // ============================================================================ function SliderBig({ label, value, setValue, min, max, step, suffix, trackBg, fixed, dens = 0 }) { const d = dens; const pct = ((value - min) / (max - min)) * 100; const display = fixed != null ? value.toFixed(fixed) : value; return (
{label.toUpperCase()}
{display}{suffix}
setValue(parseFloat(e.target.value))} style={{ // CSS custom prop on the wrapper drives the track-fill gradient ['--uni-track']: trackBg(pct), }}/>
{min}{suffix.trim()}{max}{suffix.trim()}
); } function Stat2({ k, v, accent, sub, dens = 0 }) { const d = dens; return (
{k}
{v}
{sub &&
{sub.toUpperCase()}
}
); } window.Tools = Tools;