{/* 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 */}
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) */}
);
}
// ============================================================================
// 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 (
{/* 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 */}
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',
}}
>
{/* corner badge — block tag (fades when zoomed so it doesn't obstruct) */}