← back to Interiordesignershowroom
feat: shoppable room-setting images (vision-located hotspots w/ IoU dedup) + auto room generator from CJ inventory (Gemini scene + hotspots, ~$0.04/room); rooms.hotspots jsonb; inline hotspot cards on /room/:slug
ffe693d8dba9ba19710158134d9e1546c8555c88 · 2026-08-01 22:48:55 -0700 · Steve Abrams
Files touched
M lib/hotspots.jsM lib/render.jsM public/css/site.cssA public/js/hotspots.jsM scripts/gen-room-setting.jsM server.js
Diff
commit ffe693d8dba9ba19710158134d9e1546c8555c88
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Aug 1 22:48:55 2026 -0700
feat: shoppable room-setting images (vision-located hotspots w/ IoU dedup) + auto room generator from CJ inventory (Gemini scene + hotspots, ~$0.04/room); rooms.hotspots jsonb; inline hotspot cards on /room/:slug
---
lib/hotspots.js | 17 ++++++++++++-----
lib/render.js | 29 +++++++++++++++++++++++++++-
public/css/site.css | 42 +++++++++++++++++++++++++++++++++++++++++
public/js/hotspots.js | 46 +++++++++++++++++++++++++++++++++++++++++++++
scripts/gen-room-setting.js | 2 +-
server.js | 4 ++--
6 files changed, 131 insertions(+), 9 deletions(-)
diff --git a/lib/hotspots.js b/lib/hotspots.js
index 7258f95..b6b3bb9 100644
--- a/lib/hotspots.js
+++ b/lib/hotspots.js
@@ -48,6 +48,15 @@ async function locateProducts(imagePath, products = []) {
try { raw = JSON.parse(text); } catch { raw = []; }
if (!Array.isArray(raw)) raw = [];
+ // Intersection-over-union of two %-boxes — used to reject a new hotspot that lands
+ // on the same visual object as one already accepted (lookalike products the model
+ // tagged to the single instance it could actually see in the scene).
+ const iou = (a, b) => {
+ const ix = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
+ const iy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
+ const inter = ix * iy;
+ return inter ? inter / (a.w * a.h + b.w * b.h - inter) : 0;
+ };
const seen = new Set();
const hotspots = [];
for (const d of raw) {
@@ -59,12 +68,10 @@ async function locateProducts(imagePath, products = []) {
const cl = (n) => Math.max(0, Math.min(1000, n || 0));
ymin = cl(ymin); xmin = cl(xmin); ymax = cl(ymax); xmax = cl(xmax);
if (xmax <= xmin || ymax <= ymin) continue;
+ const box = { x: xmin / 10, y: ymin / 10, w: (xmax - xmin) / 10, h: (ymax - ymin) / 10 };
+ if (hotspots.some((h) => iou(h.box, box) > 0.45)) continue; // don't stack on one object
seen.add(p.id);
- hotspots.push({
- id: p.id, title: p.title, price: p.sale_price ?? p.price,
- image_url: p.image_url, advertiser: p.advertiser,
- box: { x: xmin / 10, y: ymin / 10, w: (xmax - xmin) / 10, h: (ymax - ymin) / 10 },
- });
+ hotspots.push({ id: p.id, title: p.title, price: p.sale_price ?? p.price, image_url: p.image_url, advertiser: p.advertiser, box });
}
return { hotspots, cost: COST_PER_CALL };
}
diff --git a/lib/render.js b/lib/render.js
index aecdd24..d17e840 100644
--- a/lib/render.js
+++ b/lib/render.js
@@ -101,6 +101,33 @@ function productListJsonld(products = [], listUrl) {
return { '@context': 'https://schema.org', '@type': 'ItemList', url: listUrl, numberOfItems: items.length, itemListElement: items };
}
+// Shoppable room-setting image: the scene photo with a hotspot dot centered on each
+// vision-located piece. Tapping a dot opens an INLINE product card (image, title,
+// price, tracked Shop link) — inline, never a modal. Pieces the vision pass couldn't
+// place still appear as edge chips via the piece grid below, so nothing is unshoppable.
+function sceneFigure({ scene_image, title, hotspots = [] }) {
+ if (!scene_image) return '';
+ const spots = (hotspots || []).map((h, i) => {
+ const cx = Math.max(3, Math.min(97, h.box.x + h.box.w / 2)).toFixed(1);
+ const cy = Math.max(3, Math.min(97, h.box.y + h.box.h / 2)).toFixed(1);
+ const price = h.price != null ? money(h.price) : '';
+ return `<button class="hotspot" style="left:${cx}%;top:${cy}%" data-card="hc${i}" aria-label="Shop ${esc(h.title)}" aria-expanded="false"><span class="hotspot-dot"></span></button>
+ <div class="hotspot-card" id="hc${i}" style="left:${cx}%;top:${cy}%" role="dialog" aria-label="${esc(h.title)}" hidden>
+ ${h.image_url ? `<img src="${esc(h.image_url)}" alt="" loading="lazy">` : ''}
+ <div class="hotspot-card-body">
+ <div class="hotspot-card-title">${esc(h.title)}</div>
+ ${price ? `<div class="hotspot-card-price">${price}</div>` : ''}
+ <a class="buy" href="/go/${h.id}" rel="nofollow sponsored" target="_blank">Shop at ${esc(h.advertiser || 'store')} →</a>
+ </div>
+ </div>`;
+ }).join('');
+ return `<figure class="room-scene-wrap${hotspots.length ? ' has-spots' : ''}">
+ <img class="room-scene" src="${esc(scene_image)}" alt="AI-generated scene for ${esc(title)}">
+ <div class="hotspot-layer">${spots}</div>
+ ${hotspots.length ? '<span class="scene-hint" aria-hidden="true">Tap a dot to shop a piece</span>' : ''}
+ </figure><script src="/js/hotspots.js" defer></script>`;
+}
+
function layout({ title, description, canonical, jsonld, image, body, activeNav }) {
const ogImage = absUrl(image) || `${SITE.url}/img/og.png`;
const nav = [
@@ -161,4 +188,4 @@ ${disclosureBar()}
</html>`;
}
-module.exports = { SITE, esc, money, layout, productCard, disclosureBar, DISCLOSURE_SHORT, orgWebsiteJsonld, productListJsonld, absUrl };
+module.exports = { SITE, esc, money, layout, productCard, disclosureBar, DISCLOSURE_SHORT, orgWebsiteJsonld, productListJsonld, absUrl, sceneFigure };
diff --git a/public/css/site.css b/public/css/site.css
index 777bb3b..5ea33f9 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -740,6 +740,48 @@ h2.ruled::after {
object-fit: cover;
display: block;
}
+/* Shoppable scene: show the render at its NATURAL ratio (no 16/9 crop) so the
+ vision-located hotspot %-coordinates line up 1:1 with the visible pixels. */
+.room-scene-wrap { position: relative; display: block; margin: 20px 0; line-height: 0; }
+.room-scene-wrap .room-scene { margin: 0; aspect-ratio: auto; object-fit: contain; height: auto; }
+.hotspot-layer { position: absolute; inset: 0; }
+.hotspot {
+ position: absolute; transform: translate(-50%, -50%);
+ width: 28px; height: 28px; padding: 0; border: 0; background: none; cursor: pointer; z-index: 2;
+}
+.hotspot-dot {
+ display: block; width: 14px; height: 14px; margin: 7px; border-radius: 50%;
+ background: #fff; border: 2px solid var(--accent);
+ box-shadow: 0 0 0 3px rgba(155,123,80,.35), 0 1px 5px rgba(0,0,0,.45);
+ animation: hotspotPulse 2.4s ease-out infinite;
+}
+.hotspot:hover .hotspot-dot, .hotspot[aria-expanded="true"] .hotspot-dot { transform: scale(1.25); background: var(--accent); }
+@keyframes hotspotPulse {
+ 0% { box-shadow: 0 0 0 0 rgba(155,123,80,.5), 0 1px 5px rgba(0,0,0,.45); }
+ 70% { box-shadow: 0 0 0 11px rgba(155,123,80,0), 0 1px 5px rgba(0,0,0,.45); }
+ 100% { box-shadow: 0 0 0 0 rgba(155,123,80,0), 0 1px 5px rgba(0,0,0,.45); }
+}
+.hotspot-card {
+ position: absolute; z-index: 5; width: 210px; line-height: 1.4;
+ transform: translate(-50%, -100%) translateY(-16px);
+ background: #fff; border: 1px solid var(--line); border-radius: 6px;
+ box-shadow: 0 10px 34px rgba(0,0,0,.24); overflow: hidden;
+}
+.hotspot-card[hidden] { display: none; }
+.hotspot-card.below { transform: translate(-50%, 0) translateY(16px); }
+.hotspot-card.edge-left { transform: translate(-12%, -100%) translateY(-16px); }
+.hotspot-card.edge-left.below { transform: translate(-12%, 0) translateY(16px); }
+.hotspot-card.edge-right { transform: translate(-88%, -100%) translateY(-16px); }
+.hotspot-card.edge-right.below { transform: translate(-88%, 0) translateY(16px); }
+.hotspot-card img { width: 100%; height: 120px; object-fit: cover; display: block; }
+.hotspot-card-body { padding: 10px 12px; }
+.hotspot-card-title { font-size: .8rem; font-weight: 500; color: var(--ink); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
+.hotspot-card-price { font-size: .82rem; color: var(--accent-dk); margin: 3px 0 7px; }
+.scene-hint {
+ position: absolute; left: 10px; bottom: 10px; z-index: 2; pointer-events: none;
+ background: rgba(34,30,25,.72); color: #f3ede3; font-size: .66rem; letter-spacing: .03em;
+ padding: 3px 9px; border-radius: 20px;
+}
.room-paint {
display: flex;
gap: 16px;
diff --git a/public/js/hotspots.js b/public/js/hotspots.js
new file mode 100644
index 0000000..5edf6f6
--- /dev/null
+++ b/public/js/hotspots.js
@@ -0,0 +1,46 @@
+// Shoppable room-setting hotspots. Tapping a dot opens its inline product card;
+// the card flips above/below and clamps left/right so it never leaves the image.
+// Outside-click and Esc close it. Keyboard: dots are real <button>s, Enter toggles.
+(function () {
+ document.querySelectorAll('.room-scene-wrap.has-spots').forEach(function (wrap) {
+ var layer = wrap.querySelector('.hotspot-layer');
+ if (!layer) return;
+ var open = null;
+
+ function close() {
+ if (!open) return;
+ open.card.hidden = true;
+ open.btn.setAttribute('aria-expanded', 'false');
+ open = null;
+ }
+ function openCard(btn) {
+ close();
+ var card = wrap.querySelector('#' + btn.getAttribute('data-card'));
+ if (!card) return;
+ // vertical flip: if the dot is high in the image, drop the card below it
+ var topPct = parseFloat(card.style.top) || 50;
+ card.classList.toggle('below', topPct < 42);
+ // horizontal clamp: keep the card edge inside the figure
+ var leftPct = parseFloat(card.style.left) || 50;
+ card.classList.toggle('edge-left', leftPct < 22);
+ card.classList.toggle('edge-right', leftPct > 78);
+ card.hidden = false;
+ btn.setAttribute('aria-expanded', 'true');
+ open = { btn: btn, card: card };
+ }
+
+ layer.addEventListener('click', function (e) {
+ var btn = e.target.closest('.hotspot');
+ if (!btn) return;
+ e.preventDefault();
+ if (open && open.btn === btn) { close(); return; }
+ openCard(btn);
+ });
+ // clicks inside a card (e.g. the Shop link) shouldn't close it before navigating
+ wrap.addEventListener('click', function (e) {
+ if (!e.target.closest('.hotspot-card') && !e.target.closest('.hotspot')) close();
+ });
+ document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
+ });
+})();
diff --git a/scripts/gen-room-setting.js b/scripts/gen-room-setting.js
index a3543b1..bc44a9c 100644
--- a/scripts/gen-room-setting.js
+++ b/scripts/gen-room-setting.js
@@ -25,7 +25,7 @@ const BUCKETS = [
/sofa|sectional|loveseat|settee/i, /bed\b|headboard/i, /desk/i,
/coffee table|side table|end table|console|dining table|\btable\b/i,
/chair|stool|bench/i, /lamp|light|sconce|pendant|chandelier/i,
- /rug/i, /art|print|mirror|wall/i, /shelf|bookcase|cabinet|dresser|credenza/i,
+ /rug/i, /art|print|mirror|wall/i, /shelf|bookcase|cabinet|dresser|credenza|sideboard|buffet|console|hutch/i,
/vase|planter|decor|throw|pillow|cushion/i,
];
function diverseSet(pool, n = 6) {
diff --git a/server.js b/server.js
index 88eb374..4e236d1 100644
--- a/server.js
+++ b/server.js
@@ -3,7 +3,7 @@ const express = require('express');
const path = require('path');
const fs = require('fs');
const db = require('./lib/db');
-const { SITE, esc, layout, productCard, orgWebsiteJsonld, productListJsonld } = require('./lib/render');
+const { SITE, esc, layout, productCard, orgWebsiteJsonld, productListJsonld, sceneFigure } = require('./lib/render');
const catalog = require('./lib/catalog');
const rooms = require('./lib/rooms');
const scene = require('./lib/scene');
@@ -464,7 +464,7 @@ app.get('/room/:slug', async (req, res, next) => {
<h1>${esc(room.title)}</h1>
<p class="subtle">${esc(styleLabel)}${styleLabel && products.length ? ' · ' : ''}${products.length ? `${products.length} shoppable piece${products.length === 1 ? '' : 's'}` : ''}</p>
</div>
- ${room.scene_image ? `<img class="room-scene" src="${esc(room.scene_image)}" alt="AI-generated scene for ${esc(room.title)}">` : ''}
+ ${sceneFigure({ scene_image: room.scene_image, title: room.title, hotspots: room.hotspots || [] })}
${paintBlock}
<h2 class="shop-heading">Shop this room</h2>
${piecesGrid}
← 8df6b7b auto-save: 2026-08-01T22:40:01 (3 files) — lib/rooms.js lib/
·
back to Interiordesignershowroom
·
feat: shoppable hotspots on the live Room Builder gallery to b7e8531 →