/* Cordish Plaza — the shop picker: the model, the architect's drawing, the price list. Ammish approved this shape on 4 September: the 3D opens first because it is the easiest thing to want, his drawing is one tap away for the measured version, and the price list sits beside both so there is always something plain to read. The two renderers are plain vanilla JS in plan-3d.js and plan-as-drawn.js. This file only mounts them, hands them today's shop records, and turns a shop they report into the same thing a card used to do: add it to the enquiry form and scroll there. Exported as window.PlanPicker, rendered by Leasing in sections.jsx. */ /* The 3D hint, in plain words rather than modifier names. Most visitors are on a phone, where "right-drag" means nothing at all, so the two devices get their own sentence. What the controls actually do: one finger / left drag orbits, two fingers / the right button pans, pinch / wheel zooms. */ /* Detect touch with (any-pointer: coarse) plus maxTouchPoints, NOT "(hover: none) and (pointer: coarse)". That older test fails on stylus-capable Android phones -- a Galaxy with an S-Pen reports pointer:fine and hover:hover -- so Enoch's phone was shown the mouse sentence telling him to right-click (screenshot, 22 Sep). any-pointer asks "is ANY available pointer coarse", which is the actual question. */ const TOUCH = (typeof matchMedia === "function" && matchMedia("(any-pointer: coarse)").matches) || (typeof navigator !== "undefined" && navigator.maxTouchPoints > 0); const HINT_3D = TOUCH ? "One finger to turn it \u00b7 two fingers to zoom and slide across" : "Drag to turn it \u00b7 scroll to zoom \u00b7 right-click and drag to slide across"; function PlanPicker({ shops, selected, setSelected, leased }) { const { useState, useEffect, useRef, useMemo, useCallback } = React; const PL = window.PlazaPlan; const PLAN = window.PLAN; // asked before the first render, so no-WebGL opens on the drawing, not an apology const [view, setView] = useState(() => (PL && PL.HAS_3D() ? "3d" : "plan")); const [floor, setFloor] = useState("Ground"); const [compound, setCompound] = useState( () => (typeof matchMedia === "function" ? matchMedia("(min-width: 700px)").matches : true)); const [detail, setDetail] = useState(null); const [fs, setFs] = useState(false); const [no3d, setNo3d] = useState(false); const planMount = useRef(null); const stage = useRef(null); const groups = useRef({}); const d2 = useRef(null); const built3d = useRef(false); const live = useMemo(() => (PL && PLAN ? PL.mergePlan(PLAN, shops) : null), [shops]); const byUnit = useMemo(() => { const m = new Map(); (shops || []).forEach(s => { const n = PL && PL.numOf(s.label); if (n != null && s.floor) { m.set(s.floor + "#" + n, s); } }); return m; }, [shops]); /* The renderers attach their listeners once, at build time, so anything those listeners read has to come through a ref or they capture the first render's values and never see another. */ const byUnitRef = useRef(byUnit); const floorRef = useRef(floor); const leasedRef = useRef(leased); byUnitRef.current = byUnit; floorRef.current = floor; leasedRef.current = leased; const bump = () => requestAnimationFrame( () => window.dispatchEvent(new Event("resize"))); /* A pick is a LOOK, not a commitment (Eunice, 21 September). Tapping a price-list row, a shop on the drawing or a shop in the model only opens the detail card: the price list is for exploring. The shop joins the enquiry when the visitor says so, on the card's own button. Until then nothing is added and nothing scrolls -- the page used to do both on every tap, which made browsing impossible: one tap and you were at the form. */ const pick = useCallback((num) => { const s = byUnitRef.current.get(floorRef.current + "#" + num); if (!s) { return; } // tapping the shop you are already looking at closes it again (Eunice, 21 Sep) setDetail(prev => (prev && prev.num === num && prev.floor === floorRef.current) ? null : { id: s.id, num, floor: floorRef.current }); }, []); const clear = useCallback(() => setDetail(null), []); /* The model answers a pick by flying in on that shop and dropping lower, which slides the shop into the top of the frame -- clear of the detail card that now overlays the bottom of the stage. The card is MEASURED rather than assumed, because it wraps to two and three lines as the stage narrows. */ const detailRef = useRef(null); const cardRef = useRef(null); detailRef.current = detail; const applyFocus = useCallback(() => { const api = d2.current; if (!api || !api.focus) { return; } const d = detailRef.current; if (!d) { if (api.clearFocus) { api.clearFocus(); } return; } const card = cardRef.current; api.focus(d.num, card ? card.getBoundingClientRect().height + 22 : 0); }, []); // ---- the drawing ----------------------------------------------------- useEffect(() => { const mount = planMount.current; if (!mount || !live) { return; } /* Clear BEFORE building, not after: two SVGs in the document for even one frame means two copies of the hatch pattern id, and the second reference resolves into the first SVG. */ mount.textContent = ""; groups.current = window.buildAsDrawn(mount, floor, compound, live) || {}; Object.keys(groups.current).forEach(num => { const g = groups.current[num]; if (g.classList.contains("held")) { return; } g.addEventListener("click", () => pick(Number(num))); g.addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pick(Number(num)); } }); }); return () => { mount.textContent = ""; groups.current = {}; }; }, [live, floor, compound, pick]); // ---- the model ------------------------------------------------------- const build3d = useCallback((again) => { if (!stage.current || !live || (built3d.current && !again)) { return; } built3d.current = true; if (d2.current && d2.current.dispose) { d2.current.dispose(); } stage.current.querySelectorAll("canvas").forEach(c => c.remove()); const frame = PL.frameFor(stage.current.clientWidth || 700); /* window.NO_3D is set by NO_3D_HTML() and nothing clears it, so a single failed attempt used to leave "3D is not available on this device" on screen for good -- including next to a model that had since built fine. Judge each attempt on what it actually produced: a canvas in the stage, or not. */ window.NO_3D = false; try { d2.current = window.buildD2({ stage: stage.current, floor: floor, plan: live, onPick: pick, onClear: clear, camera: frame.camera, target: frame.target, }); } catch (e) { d2.current = null; } setNo3d(!stage.current.querySelector("canvas")); applyFocus(); // the model is lazy, so it can be built AFTER the pick it answers bump(); }, [live, floor, pick, clear, applyFocus]); useEffect(() => { if (view !== "3d" || !live || !PL.HAS_3D() || built3d.current) { return; } let alive = true, queued = false; /* The model is the heaviest thing on the page -- 630 KB of three.js before its own scene -- and the section sits below the fold, so it waits until it is nearly in view. Switch to the drawing before then and it is never fetched at all. Measured against its own rect on scroll rather than with an IntersectionObserver: the observer delivered no callback whatsoever under test, not even the initial one, and the 3D is the DEFAULT view here. A stage that stays blank because a callback never arrived is not a trade worth making for the tidier API. */ const near = () => { const el = stage.current; if (!el) { return false; } const r = el.getBoundingClientRect(); return r.top < innerHeight + 400 && r.bottom > -400; }; const detach = () => { window.removeEventListener("scroll", check, true); window.removeEventListener("resize", check); }; const start = () => { detach(); PL.loadThree().then(() => { if (alive) { build3d(false); } }) .catch(() => { if (alive) { setNo3d(true); } }); }; function check() { if (queued) { return; } queued = true; requestAnimationFrame(() => { queued = false; if (alive && near()) { start(); } }); } if (near()) { start(); } else { window.addEventListener("scroll", check, true); // capture: inner scrollers too window.addEventListener("resize", check); } return () => { alive = false; detach(); }; }, [view, live, build3d]); // the model bakes its floor's rents into its labels, so a floor switch rebuilds it useEffect(() => { if (built3d.current) { build3d(true); } }, [floor, live]); // an orphan render loop drawing into a detached canvas outlives the page otherwise useEffect(() => () => { if (d2.current && d2.current.dispose) { d2.current.dispose(); } d2.current = null; }, []); useEffect(() => { bump(); }, [view, fs]); useEffect(() => { applyFocus(); }, [detail, applyFocus]); /* The card overlays the visual, so it is usually on screen already. The drawing is tall, though, so on a laptop its foot can sit below the fold. Nudge -- minimally, and only when the card is actually out of view. */ useEffect(() => { if (!detail || fs || !cardRef.current) { return; } const r = cardRef.current.getBoundingClientRect(); if (r.top >= 0 && r.bottom <= innerHeight - 8) { return; } cardRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" }); }, [detail, fs]); /* The download buttons live in the express-interest section, injected by marketing-image.js outside React, so the floor reaches them through here. Someone looking at the first floor should get the first floor's sheet. */ useEffect(() => { window.PLAZA_EXPORT_FLOOR = floor; }, [floor]); // the no-3D fallback's button is injected HTML, so it reaches us by event useEffect(() => { const back = () => setView("plan"); document.addEventListener("plaza:show-plan", back); return () => document.removeEventListener("plaza:show-plan", back); }, []); useEffect(() => { if (!fs) { return; } const esc = e => { if (e.key === "Escape") { setFs(false); } }; document.addEventListener("keydown", esc); document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", esc); document.body.style.overflow = ""; }; }, [fs]); /* ONE mark: gold is the shop being looked at. The enquiry deliberately does NOT mark the drawing or the list -- picking a shop in the Express interest section must leave the explore views alone (Eunice, 21 Sep). What you have collected is shown where you collect it: the chips, and the card's own Added state. */ useEffect(() => { const g = groups.current || {}; Object.keys(g).forEach(num => { g[num].classList.toggle("look", !!(detail && detail.floor === floor && String(detail.num) === String(num))); }); }, [floor, live, compound, view, detail]); if (!live || !PL) { return null; } const nums = Object.keys(live.floors[floor].shops).map(Number).sort((a, b) => a - b); const free = nums.filter(n => live.status(floor, n) === "Available").length; const is3d = view === "3d"; const shopOf = num => byUnit.get(floor + "#" + num) || null; const seg = (opts, val, set) => React.createElement("div", { className: "p-seg" }, opts.map((o, i) => React.createElement("button", { key: o.v, type: "button", onClick: () => set(o.v), "aria-pressed": val === o.v, className: val === o.v ? "on" : "" }, o.label, o.count != null ? React.createElement("span", { className: "num cnt" }, o.count) : null))); const priceRow = (num) => { const d = live.floors[floor].shops[num]; const status = live.status(floor, num); const held = status !== "Available" || leased; const looking = !!(detail && detail.floor === floor && detail.num === num); return React.createElement("tr", { key: num, className: (held ? "held" : "") + (looking ? " look" : ""), tabIndex: held ? undefined : 0, onClick: held ? undefined : () => pick(num), onKeyDown: held ? undefined : (e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pick(num); } }) }, React.createElement("td", null, PLAN.shopTag(floor, num)), React.createElement("td", { className: "num" }, d.size + " m²"), React.createElement("td", { className: "num" }, held ? (window.OFF_MARKET_LABEL ? window.OFF_MARKET_LABEL(status) : "Reserved") : (d.rent != null ? d.rent.toLocaleString("en-GH") : "—"))); }; const panel = () => { if (!detail) { return null; } const d = live.floors[detail.floor].shops[detail.num]; const status = live.status(detail.floor, detail.num); const held = status !== "Available" || leased; const s = shopOf(detail.num); const tag = PLAN.shopTag(detail.floor, detail.num); /* Short on purpose: the long sentence was wide enough to fill the row on its own, which pushed the badge and the button onto a second line. Eunice's own wording for this figure, 31 July. */ const enq = s && s.interested_count > 0 ? (s.interested_count === 1 ? "1 enquiry" : s.interested_count + " enquiries") : null; const picked = !!(s && selected.has(s.id)); return React.createElement("div", { className: "p-detail", ref: cardRef }, React.createElement("div", { className: "p-detail-num" }, tag), React.createElement("div", { className: "p-detail-meta" }, React.createElement("b", null, "Shop " + tag + ", " + detail.floor.toLowerCase() + " floor"), React.createElement("br"), React.createElement("span", { className: "num" }, d.size + " m²" + (held || d.rent == null ? "" : " · GHS " + d.rent.toLocaleString("en-GH") + " per month"))), /* One tag, not two: the enquiry count TAKES the availability slot and its styling (Eunice, 21 Sep). A shop nobody has asked about still says "Available now", so the slot is never empty, and a held shop keeps its off-market note -- there the note is the whole message. */ React.createElement("span", { className: "p-badge" + (held ? " held" : "") }, held ? (window.OFF_MARKET_NOTE ? window.OFF_MARKET_NOTE(status) : "Not available to let") : (enq || "Available now")), /* A tap on a shop is still only a look. This button is the deliberate step, so it DOES take them to the form (Eunice, 21 Sep). */ !held && s ? React.createElement("button", { type: "button", className: "p-cta" + (picked ? " added" : ""), onClick: () => { /* Single-select since 22 Sep: one enquiry is about one shop, so this MOVES the selection rather than adding to it. The form sends them one at a time and offers "Express interest in another" after each. */ setSelected(new Set([s.id])); scrollToId("interest"); } }, picked ? "Added \u2713" : "Express interest") : null, React.createElement("button", { type: "button", className: "p-close", onClick: clear, "aria-label": "Close" }, "\u00d7")); }; return React.createElement("div", { className: "p-picker" }, React.createElement("div", { className: "p-controls" }, seg([{ v: "3d", label: "See it in 3D" }, { v: "plan", label: "Floor plan" }], view, setView), seg([{ v: "Ground", label: "Ground", count: Object.keys(live.floors.Ground.shops).length }, { v: "First", label: "First", count: Object.keys(live.floors.First.shops).length }], floor, setFloor), no3d ? React.createElement("span", { className: "p-note" }, "3D is not available on this device") : null), React.createElement("div", { className: "p-sheet" + (fs ? " fs" : "") }, React.createElement("div", { className: "p-sheet-head" }, React.createElement("div", null, React.createElement("div", { className: "p-sheet-title" }, is3d ? "The building in 3D" : "Ground & First Floor Plan"), React.createElement("div", { className: "p-sheet-kicker" }, live.floors[floor].title + " · " + nums.length + " shops · " + (leased ? "none available" : free === nums.length ? "all available now" : free + " available now"))), React.createElement("div", { className: "p-sheet-tools" }, !is3d ? React.createElement("label", { className: "p-check" }, React.createElement("input", { type: "checkbox", checked: compound, onChange: e => setCompound(e.target.checked) }), "Show the compound") : null, React.createElement("button", { type: "button", className: "p-fsbtn", onClick: () => setFs(v => !v) }, fs ? "Close" : "Full screen"))), React.createElement("div", { className: "p-sheet-body" }, React.createElement("div", { className: "p-visual" }, React.createElement("div", { className: "p-stage", ref: stage, hidden: !is3d }, detail ? null : React.createElement("div", { className: "p-hintpill" }, HINT_3D)), React.createElement("div", { className: "p-plan", ref: planMount, hidden: is3d }), /* The card sits OVER the bottom of whichever view is showing, so the answer to a tap is next to the thing tapped -- and so it still exists in full screen, where a panel below the sheet is unreachable. */ panel()), React.createElement("div", { className: "p-list" }, React.createElement("div", { className: "p-list-h" }, "Price list"), React.createElement("table", null, React.createElement("thead", null, React.createElement("tr", null, React.createElement("th", null, "Shop"), React.createElement("th", null, "Size"), React.createElement("th", null, "Rent ", React.createElement("span", null, "GHS/mo")))), React.createElement("tbody", null, nums.map(priceRow))))), /* One disclaimer for both views: the drawing is our trace of the architect's sheet, so it is as simplified as the model is. */ React.createElement("p", { className: "p-sheet-foot" }, "Indicative visual, not a measured drawing: shop positions and proportions are simplified so the shops are easy to compare."))); } Object.assign(window, { PlanPicker });