← back to Ventura Corridor
iter 64: building pitch-type heatmap — /api/buildings/:bldg/pitch-types returns counts per pitch_type for one building (no-pitch counted as its own bucket); /buildings.html building card head gains a 'dominant' pill (italic serif) showing the most-common pitch_type from v_building_roster.dominant_pitch_type; expanded roster prepends a horizontal vertical-mix bar with per-type colors (law-office=tan, medical=teal, real-estate=blue, restaurant=red, etc.) + legend showing top 6 buckets with counts; reveals at-a-glance that 16133 Ventura is 78 law + 74 real-estate + 26 medical out of 405 tenants (224 unpitched), 15303 is law-dominant
e654bdfb05739275001876de6fe0615aa25a384e · 2026-05-06 14:39:34 -0700 · SteveStudio2
Files touched
M public/buildings.htmlM src/server/index.ts
Diff
commit e654bdfb05739275001876de6fe0615aa25a384e
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Wed May 6 14:39:34 2026 -0700
iter 64: building pitch-type heatmap — /api/buildings/:bldg/pitch-types returns counts per pitch_type for one building (no-pitch counted as its own bucket); /buildings.html building card head gains a 'dominant' pill (italic serif) showing the most-common pitch_type from v_building_roster.dominant_pitch_type; expanded roster prepends a horizontal vertical-mix bar with per-type colors (law-office=tan, medical=teal, real-estate=blue, restaurant=red, etc.) + legend showing top 6 buckets with counts; reveals at-a-glance that 16133 Ventura is 78 law + 74 real-estate + 26 medical out of 405 tenants (224 unpitched), 15303 is law-dominant
---
public/buildings.html | 40 +++++++++++++++++++++++++++++++++++++---
src/server/index.ts | 21 +++++++++++++++++++++
2 files changed, 58 insertions(+), 3 deletions(-)
diff --git a/public/buildings.html b/public/buildings.html
index 08877c2..7eb3591 100644
--- a/public/buildings.html
+++ b/public/buildings.html
@@ -252,6 +252,7 @@ function render(rows) {
<div class="stat-cell"><div class="v metal">${pitched}</div><div class="l">pitched</div></div>
<div class="stat-cell"><div class="v green">${walked}</div><div class="l">walked</div></div>
<div class="stat-cell"><div class="v green">${b.won || 0}</div><div class="l">won</div></div>
+ ${b.dominant_pitch_type ? `<div class="stat-cell"><div class="v" style="font-family:var(--serif);font-style:italic;color:var(--metal-glow);font-size:13px">${escapeHtml(b.dominant_pitch_type.replace(/-/g,' '))}</div><div class="l">dominant</div></div>` : ''}
</div>
<div class="bldg-bar">
<div class="seg-walked" style="width:${100*walked/total}%" title="walked: ${walked}"></div>
@@ -273,9 +274,10 @@ async function toggleBldg(addrEl) {
if (roster.dataset.loaded === '1') return;
roster.innerHTML = `<div class="loading" style="padding:14px">Loading roster…</div>`;
try {
- const [data, dupesData] = await Promise.all([
+ const [data, dupesData, ptData] = await Promise.all([
fetch(`/api/buildings/${bldgEl.dataset.addr}`).then(r => r.json()),
- fetch(`/api/buildings/${bldgEl.dataset.addr}/dupes`).then(r => r.json()).catch(() => ({ pairs: [] }))
+ fetch(`/api/buildings/${bldgEl.dataset.addr}/dupes`).then(r => r.json()).catch(() => ({ pairs: [] })),
+ fetch(`/api/buildings/${bldgEl.dataset.addr}/pitch-types`).then(r => r.json()).catch(() => ({ rows: [] }))
]);
const rows = data.rows || [];
roster.dataset.loaded = '1';
@@ -313,7 +315,39 @@ async function toggleBldg(addrEl) {
</div>
`).join('')}
</div>` : '';
- roster.innerHTML = dupePairsHtml + `
+ // Pitch-type heatmap (iter 64) — horizontal bar showing top types in this building
+ const PT_COLORS = {
+ 'law-office': '#c4b07b',
+ 'medical-clinic': '#7ab8a3',
+ 'dental-office': '#9bc4a3',
+ 'real-estate': '#7b9bc4',
+ 'restaurant': '#c47b7b',
+ 'salon-spa': '#c47ba6',
+ 'beauty-spa': '#c47ba6',
+ 'retail-storefront': '#c4a07b',
+ 'office-tenant': '#a3a3c4',
+ 'fitness-studio': '#a3c47b',
+ 'hospitality': '#c4a05c',
+ 'building-neighbor': '#d4b683',
+ '(no pitch)': '#3a3530',
+ };
+ const ptRows = (ptData.rows || []).slice(0, 6);
+ const ptTotal = (ptData.rows || []).reduce((a, x) => a + Number(x.n), 0);
+ const heatmapHtml = ptTotal > 0 ? `
+ <div style="margin-bottom:14px">
+ <div style="font-size:9px;letter-spacing:.26em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:6px">Vertical mix · ${ptTotal} tenants</div>
+ <div style="display:flex;height:14px;border:1px solid var(--rule);overflow:hidden">
+ ${(ptData.rows || []).map(r => `
+ <div style="background:${PT_COLORS[r.pitch_type] || '#555'};width:${100 * Number(r.n) / ptTotal}%" title="${escapeHtml(r.pitch_type)}: ${r.n}"></div>
+ `).join('')}
+ </div>
+ <div style="display:flex;gap:14px;flex-wrap:wrap;margin-top:6px;font-size:9px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-mute)">
+ ${ptRows.map(r => `
+ <span><span style="display:inline-block;width:10px;height:10px;background:${PT_COLORS[r.pitch_type] || '#555'};margin-right:4px;vertical-align:middle"></span>${escapeHtml(r.pitch_type.replace(/-/g, ' '))} <b style="color:var(--ink);font-family:var(--mono)">${r.n}</b></span>
+ `).join('')}
+ </div>
+ </div>` : '';
+ roster.innerHTML = heatmapHtml + dupePairsHtml + `
<table>
<thead><tr>
<th>Suite</th><th>Tenant</th><th>NAICS</th><th>Status</th><th>Channel</th><th>Sent</th><th>Reply</th><th></th>
diff --git a/src/server/index.ts b/src/server/index.ts
index 29c6b01..b758789 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -1490,6 +1490,27 @@ app.post('/api/buildings/dupes/dismiss', express.json(), async (req, res) => {
}
});
+// Pitch-type distribution for one building (powers the heatmap bar in /buildings.html)
+app.get('/api/buildings/:bldg/pitch-types', async (req, res) => {
+ try {
+ const bldg = decodeURIComponent(req.params.bldg);
+ const r = await query(
+ `SELECT
+ COALESCE(p.pitch_type, '(no pitch)') AS pitch_type,
+ count(*) AS n
+ FROM v_building_canonical bc
+ LEFT JOIN pitches p ON p.business_id = bc.id
+ WHERE bc.bldg_address = $1
+ GROUP BY p.pitch_type
+ ORDER BY n DESC`,
+ [bldg]
+ );
+ res.json({ bldg_address: bldg, total: r.rows.reduce((a, x) => a + Number(x.n), 0), rows: r.rows });
+ } catch (e: any) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
app.get('/api/buildings/:bldg/dupes', async (req, res) => {
try {
const bldg = decodeURIComponent(req.params.bldg);
← 1c3e46f iter 63: 🏇 crawl derby — racetrack-style live progress view
·
back to Ventura Corridor
·
iter 65: /api/buildings.csv export + activity-feed event-typ 10506e0 →