← back to Ventura Corridor
public/index.html
502 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<script>(function(){try{var t=localStorage.getItem('vc-theme');document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(e){document.documentElement.dataset.theme='dark';}})();</script>
<link rel="stylesheet" href="/theme.css">
<script src="/theme.js" defer></script>
<title>Ventura Blvd Corridor — local viewer</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
<style>
:root {
--noir: #0a0a0c; --noir-rise: #131316; --rule: #2a2724;
--ink: #f4f1ea; --ink-soft: #d8d2c5; --ink-mute: #8b857a;
--metal: #b89968; --metal-glow: #d4b683; --metal-deep: #8a7044;
--serif: "Cormorant Garamond", Georgia, serif;
--sans: -apple-system, "SF Pro Text", "Inter", system-ui, sans-serif;
}
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--noir); color: var(--ink); font-family: var(--sans); font-weight: 300; }
header { position: fixed; top: 0; left: 0; right: 0; z-index: 1000; padding: 16px 22px; background: linear-gradient(180deg, rgba(10,10,12,.92), rgba(10,10,12,0)); backdrop-filter: blur(8px); display: flex; align-items: baseline; justify-content: space-between; gap: 24px; }
h1 { font-family: var(--serif); font-weight: 300; font-size: 22px; margin: 0; letter-spacing: -0.005em; }
h1 em { font-style: italic; color: var(--metal); }
.stats { font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--ink-mute); display: flex; gap: 22px; flex-wrap: wrap; }
.stats b { color: var(--metal); font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 14px; letter-spacing: 0.01em; text-transform: none; margin-right: 6px; font-variant-numeric: tabular-nums; }
#map { position: absolute; inset: 0; }
.leaflet-tile-pane { filter: invert(1) hue-rotate(180deg) saturate(0.6) brightness(0.92) contrast(0.92); }
/* Grid search */
.gs-wrap { position: fixed; top: 56px; left: 22px; z-index: 950; width: clamp(280px, 32vw, 420px); }
.gs-input { width: 100%; padding: 14px 18px; background: var(--noir-rise); border: 1px solid var(--rule); color: var(--ink); font-family: var(--sans); font-size: 14px; font-weight: 300; letter-spacing: 0.005em; outline: none; transition: border-color 200ms ease; }
.gs-input::placeholder { color: var(--ink-mute); font-style: italic; font-family: var(--serif); }
.gs-input:focus { border-color: var(--metal); box-shadow: 0 0 0 3px rgba(184,153,104,0.12); }
.gs-results { background: var(--noir-rise); border: 1px solid var(--rule); border-top: none; max-height: 60vh; overflow-y: auto; display: none; }
.gs-results.show { display: block; }
.gs-empty { padding: 18px; font-family: var(--serif); font-style: italic; color: var(--ink-mute); font-size: 13px; }
.gs-grid { display: grid; grid-template-columns: 1fr; gap: 0; }
/* Search cells — clean editorial: name on top, meta beneath. No dots
(the color-dot pattern is a DW-micro-site convention, not universal). */
.gs-cell { padding: 14px 18px; border-bottom: 1px solid var(--rule); cursor: pointer; transition: background 160ms ease, border-color 160ms ease; display: flex; flex-direction: column; gap: 4px; }
.gs-cell:last-child { border-bottom: none; }
.gs-cell:hover { background: rgba(184,153,104,0.04); border-bottom-color: var(--metal); }
.gs-cell-name { font-family: var(--serif); font-weight: 400; color: var(--ink); font-size: 15px; line-height: 1.18; letter-spacing: -0.005em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gs-cell-meta { font-size: 11px; color: var(--ink-mute); letter-spacing: 0.04em; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gs-cell-meta .pin { color: var(--metal); font-style: italic; font-family: var(--serif); font-size: 12px; margin-right: 4px; }
.gs-cell-meta .domain { color: var(--metal); font-family: 'SFMono-Regular', Menlo, Consolas, monospace; font-size: 10px; letter-spacing: 0; text-transform: lowercase; margin-left: 6px; opacity: 0.85; }
.gs-count { padding: 8px 18px; font-size: 9px; letter-spacing: 0.28em; text-transform: uppercase; color: var(--ink-mute); border-bottom: 1px solid var(--rule); background: rgba(0,0,0,0.2); }
/* Detail panel */
#panel { position: fixed; top: 56px; right: 0; bottom: 0; width: 380px; max-width: 90vw; z-index: 900; background: var(--noir-rise); border-left: 1px solid var(--rule); transform: translateX(100%); transition: transform 360ms cubic-bezier(.16,.84,.3,1); overflow-y: auto; }
#panel.show { transform: none; }
#panel .close { position: absolute; top: 14px; right: 16px; background: transparent; color: var(--ink-mute); border: 1px solid var(--rule); padding: 6px 10px; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; font-weight: 500; cursor: pointer; }
#panel .close:hover { color: var(--metal); border-color: var(--metal); }
.pn-body { padding: 28px 28px 40px; }
.pn-eyebrow { font-size: 10px; letter-spacing: 0.28em; text-transform: uppercase; color: var(--metal); font-weight: 500; margin: 0 0 10px; }
.pn-name { font-family: var(--serif); font-weight: 400; font-size: 24px; line-height: 1.18; color: var(--ink); margin: 0 0 8px; letter-spacing: -0.005em; }
.pn-meta { font-size: 12px; color: var(--ink-mute); line-height: 1.6; margin: 0 0 18px; }
.pn-row { display: flex; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid var(--rule); font-size: 13px; }
.pn-row .k { color: var(--ink-mute); letter-spacing: 0.04em; }
.pn-row .v { color: var(--ink); font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 14px; }
.pn-row .v.metal { color: var(--metal); }
.pn-website { display: block; margin-top: 18px; padding: 14px 18px; border: 1px solid var(--metal); color: var(--metal-glow); font-size: 11px; letter-spacing: 0.22em; text-transform: uppercase; font-weight: 500; text-align: center; text-decoration: none; transition: background 200ms ease, color 200ms ease; }
.pn-website:hover { background: var(--metal); color: var(--noir); }
.pn-empty { font-style: italic; color: var(--ink-mute); font-family: var(--serif); font-size: 14px; }
.leaflet-popup-content-wrapper { background: var(--noir-rise); color: var(--ink); border-radius: 0; border: 1px solid var(--rule); }
.leaflet-popup-content { margin: 12px 16px; font-family: var(--sans); font-size: 12px; }
.leaflet-popup-content b { font-family: var(--serif); font-weight: 400; font-size: 14px; color: var(--metal-glow); display: block; margin-bottom: 4px; }
.leaflet-popup-tip { background: var(--noir-rise); border: 1px solid var(--rule); }
/* Live build-status strip — bottom-right */
#build { position: fixed; bottom: 16px; right: 16px; z-index: 950; background: var(--noir-rise); border: 1px solid var(--rule); padding: 14px 18px; max-width: 360px; font-size: 11px; line-height: 1.5; color: var(--ink-soft); transition: opacity 220ms ease; }
#build.refreshing { opacity: 0.6; }
#build .b-eyebrow { font-size: 9px; letter-spacing: .3em; text-transform: uppercase; color: var(--metal); font-weight: 500; margin: 0 0 8px; display: flex; justify-content: space-between; align-items: center; }
#build .b-eyebrow .ts { color: var(--ink-mute); letter-spacing: .12em; font-style: italic; font-family: var(--serif); text-transform: none; font-size: 10px; }
#build .b-grid { display: grid; grid-template-columns: 1fr auto; gap: 4px 14px; font-variant-numeric: tabular-nums; }
#build .b-grid b { font-family: var(--serif); font-style: italic; font-weight: 400; color: var(--metal); font-size: 13px; text-align: right; letter-spacing: 0.005em; }
#build .b-grid span { color: var(--ink-mute); }
#build .b-divider { height: 1px; background: var(--rule); margin: 10px 0 8px; }
#build .b-tier { display: flex; gap: 8px; margin-top: 6px; }
#build .b-tier .t { flex: 1; padding: 6px 4px; text-align: center; border: 1px solid var(--rule); }
#build .b-tier .t b { display: block; font-family: var(--serif); font-style: italic; font-size: 14px; font-weight: 400; }
#build .b-tier .t.A { color: var(--metal-glow); border-color: var(--metal); }
#build .b-tier .t.B { color: var(--metal); }
#build .b-tier .t.C { color: #a07a40; }
#build .b-tier .t.D { color: #5a4220; opacity: 0.85; }
#build .b-tier .t span { display: block; font-size: 8px; letter-spacing: .22em; text-transform: uppercase; color: var(--ink-mute); margin-top: 2px; }
#build a.b-3d { display: block; margin-top: 12px; padding: 8px 12px; border: 1px solid var(--metal); color: var(--metal-glow); text-align: center; font-size: 10px; letter-spacing: .22em; text-transform: uppercase; text-decoration: none; transition: background 220ms ease, color 220ms ease; }
#build a.b-3d:hover { background: var(--metal); color: var(--noir); }
</style>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
</head>
<body>
<header>
<h1>Ventura <em>Boulevard</em> · corridor view <span class="theme-toggle-host" style="display:inline-flex;align-items:center;margin-left:12px;vertical-align:middle"></span></h1>
<div class="stats" id="stats">
<span><b id="s-total">—</b> on corridor</span>
<span><b id="s-site">—</b> with website</span>
<span><b id="s-scored">—</b> scored</span>
</div>
</header>
<div class="gs-wrap">
<input type="search" class="gs-input" id="gs-input" placeholder="Search Ventura Blvd businesses…" aria-label="Search businesses" autocomplete="off">
<div class="gs-results" id="gs-results">
<div class="gs-empty">Type to search the corridor.</div>
</div>
</div>
<div id="map"></div>
<div id="build" aria-live="polite">
<p class="b-eyebrow"><span>Build status</span><span class="ts" id="b-ts">—</span></p>
<div class="b-grid">
<span>Businesses on corridor</span><b id="b-businesses">—</b>
<span>With website</span><b id="b-with-site">—</b>
<span>Front-page audited</span><b id="b-audited">—</b>
<span>SEO scored</span><b id="b-scored">—</b>
<span>Ownership classified</span><b id="b-enriched">—</b>
<span>With LinkedIn contact</span><b id="b-owner">—</b>
<span><a href="/news.html" style="color:inherit;text-decoration:none;border-bottom:1px dotted var(--metal)">News articles</a></span><b id="b-news"><a href="/news.html" style="color:var(--metal-glow);text-decoration:none">—</a></b>
<span>News biz</span><b id="b-news-biz">—</b>
<span>Fresh ‹7d</span><b id="b-news-7d">—</b>
<span><a href="http://127.0.0.1:9920/" target="_blank" rel="noopener" style="color:inherit;text-decoration:none;border-bottom:1px dotted var(--metal)">Ideas ✓</a></span><b id="b-ideas">—</b>
<span style="color:#c47b7b">Ideas ✗</span><b id="b-ideas-rej" style="color:#c47b7b">—</b>
</div>
<div class="b-divider"></div>
<div class="b-fleet" id="b-fleet" style="font-family:var(--mono);font-size:10px;letter-spacing:.14em;color:var(--ink-mute);text-transform:uppercase;display:flex;gap:18px;align-items:center;flex-wrap:wrap">
<span style="color:var(--metal)">Fleet</span>
<span id="fleet-mac1">Mac1 ·</span>
<span id="fleet-mac2">Mac2 ·</span>
</div>
<div class="b-divider"></div>
<div class="b-tier">
<div class="t A"><b id="b-A">—</b><span>A · 80+</span></div>
<div class="t B"><b id="b-B">—</b><span>B · 60+</span></div>
<div class="t C"><b id="b-C">—</b><span>C · 40+</span></div>
<div class="t D"><b id="b-D">—</b><span>D · <40</span></div>
</div>
<a class="b-3d" href="/3d.html">Three.js corridor ↗</a>
<a class="b-3d" href="/wall.html" style="margin-top:6px">The wall — every front page ↗</a>
<a class="b-3d" href="/signals.html" style="margin-top:6px">Ad signals — who's paying for ads ↗</a>
<a class="b-3d" href="/dw-radar.html" style="margin-top:6px">DW radar — interior + furniture + design ↗</a>
<a class="b-3d" href="/timeline.html" style="margin-top:6px">Timeline — films & archival photos ↗</a>
<a class="b-3d" href="/pantone.html" style="margin-top:6px">Pantone — the boulevard's color ↗</a>
<a class="b-3d" href="/headlines.html" style="margin-top:6px">Voices — every business's H1 ↗</a>
<a class="b-3d" href="/tongues.html" style="margin-top:6px">Tongues — H1s in geographic position ↗</a>
<a class="b-3d" href="/chains.html" style="margin-top:6px">Chains — the parking-economy story ↗</a>
<a class="b-3d" href="/atlas.html" style="margin-top:6px">Atlas — corridor at a glance ↗</a>
<a class="b-3d" href="/eulogy.html" style="margin-top:6px">Eulogy — websites that no longer answer ↗</a>
<a class="b-3d" href="/marketplace.html" style="margin-top:6px;border-color:var(--metal-glow);color:var(--metal-glow)"><b>Marketplace</b> · local board ↗</a>
<a class="b-3d" href="/today.html" style="margin-top:6px;border-color:var(--metal-glow);color:var(--metal-glow)"><b>Today</b> · daily action plan ↗</a>
<a class="b-3d" href="/pitches.html" style="margin-top:6px;border-color:var(--metal);color:var(--metal)">DW pitch pipeline (admin) ↗</a>
<a class="b-3d" href="/linkedin.html" style="margin-top:6px;border-color:var(--metal);color:var(--metal)">LinkedIn agent (admin) ↗</a>
<!-- Voices of the boulevard — random Sharp/Exceptional headlines -->
<div id="voices-tease" style="margin-top:24px;padding-top:18px;border-top:1px solid var(--rule);display:none">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px">
<div style="font-size:9px;letter-spacing:.26em;text-transform:uppercase;color:var(--metal)">hear the boulevard</div>
<button id="voices-shuffle" type="button" style="background:transparent;border:1px solid var(--rule);color:var(--ink-mute);padding:3px 8px;font-size:9px;letter-spacing:.2em;text-transform:uppercase;cursor:pointer;font-family:inherit">↻ shuffle</button>
</div>
<div id="voices-list" style="display:flex;flex-direction:column;gap:12px"></div>
</div>
</div>
<aside id="panel">
<button class="close" aria-label="Close panel" onclick="document.getElementById('panel').classList.remove('show')"><span aria-hidden="true">close ×</span></button>
<div class="pn-body" id="panel-body">
<p class="pn-empty">Click a marker to see business details.</p>
</div>
</aside>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script>
const map = L.map('map', { zoomControl: true }).setView([34.155, -118.495], 12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, attribution: '© OpenStreetMap'
}).addTo(map);
async function loadStats() {
try {
const r = await fetch('/api/stats').then(r => r.json());
document.getElementById('s-total').textContent = (r.businesses_corridor || 0).toLocaleString();
document.getElementById('s-site').textContent = (r.businesses_with_site || 0).toLocaleString();
document.getElementById('s-scored').textContent = (r.scored_total || 0).toLocaleString();
} catch (e) { /* no data yet */ }
}
function tierColor(score) {
if (score == null) return '#5a4220';
if (score >= 90) return '#d4b683';
if (score >= 70) return '#b89968';
if (score >= 50) return '#a07a40';
return '#5a4220';
}
// Pull just the host out of a URL — never expose path/query in the search row.
// Defensive: returns '' on bad input rather than throwing.
function hostOnly(url) {
try {
const u = new URL(/^https?:\/\//.test(url) ? url : 'https://' + url);
return u.hostname.replace(/^www\./, '');
} catch { return ''; }
}
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
// Cached dataset for grid-search filtering (loaded once on page open)
let DATASET = [];
const MARKERS = new Map(); // business id → L.circleMarker
async function loadBusinesses() {
try {
// Pull both rails in one query. OSM rows = audited (visible bigger).
// BTRC rows = registered-but-not-yet-audited (smaller, dimmer).
const r = await fetch('/api/businesses?limit=20000').then(r => r.json());
if (!r.rows || !r.rows.length) return;
DATASET = r.rows;
const audited = L.featureGroup().addTo(map);
const registered = L.featureGroup().addTo(map);
for (const b of r.rows) {
if (b.lat == null || b.lng == null) continue;
const isOsm = b.source === 'osm';
const m = L.circleMarker([b.lat, b.lng], {
radius: isOsm ? 5 : 2.5,
color: isOsm ? tierColor(b.seo_score) : '#6b6359',
weight: isOsm ? 1 : 0.5,
fillOpacity: isOsm ? 0.85 : 0.45,
});
m.on('click', () => showPanel(b.id));
m.bindTooltip(b.name || '(no name)', { direction: 'top', opacity: 0.9 });
(isOsm ? audited : registered).addLayer(m);
MARKERS.set(b.id, m);
}
map.fitBounds(audited.getBounds(), { padding: [40, 40] });
} catch (e) { console.error(e); }
}
// ─── Grid search — live as-you-type filter over loaded dataset ─────────
const gsInput = document.getElementById('gs-input');
const gsResults = document.getElementById('gs-results');
let gsDebounce;
function gsRender(rows, q) {
if (!q) {
gsResults.classList.remove('show');
return;
}
if (!rows.length) {
gsResults.classList.add('show');
gsResults.innerHTML = `<div class="gs-empty">No businesses match "${q}".</div>`;
return;
}
// Each result shows the business's OWN domain only — never cross-link.
const cells = rows.slice(0, 60).map(b => {
const block = b.corridor_block ? `№ ${b.corridor_block}` : '';
const meta = [b.category, b.city, block].filter(Boolean).join(' · ');
const domain = b.website ? hostOnly(b.website) : '';
return `
<div class="gs-cell" data-id="${b.id}">
<span class="gs-cell-name">${escapeHtml(b.name || '(no name)')}${domain ? '<span class="domain">' + escapeHtml(domain) + '</span>' : ''}</span>
<span class="gs-cell-meta">${escapeHtml(meta)}</span>
</div>`;
}).join('');
gsResults.classList.add('show');
gsResults.innerHTML = `
<div class="gs-count">${rows.length} match${rows.length === 1 ? '' : 'es'}${rows.length > 60 ? ' · top 60 shown' : ''}</div>
<div class="gs-grid">${cells}</div>`;
gsResults.querySelectorAll('.gs-cell').forEach(el => {
el.addEventListener('click', () => {
const id = parseInt(el.dataset.id, 10);
const b = DATASET.find(x => x.id === id);
if (b && b.lat != null) map.flyTo([b.lat, b.lng], 17, { duration: 0.6 });
const m = MARKERS.get(id);
if (m) m.openPopup();
showPanel(id);
});
});
}
function gsSearch(q) {
q = q.trim().toLowerCase();
if (!q) { gsRender([], ''); return; }
const matches = DATASET.filter(b => {
if ((b.name || '').toLowerCase().includes(q)) return true;
if ((b.category || '').toLowerCase().includes(q)) return true;
if ((b.address || '').toLowerCase().includes(q)) return true;
if ((b.city || '').toLowerCase().includes(q)) return true;
return false;
});
gsRender(matches, q);
}
gsInput.addEventListener('input', e => {
clearTimeout(gsDebounce);
gsDebounce = setTimeout(() => gsSearch(e.target.value), 150);
});
gsInput.addEventListener('focus', e => { if (e.target.value) gsSearch(e.target.value); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { gsInput.value = ''; gsRender([], ''); gsInput.blur(); }
});
// Click outside the search wrapper to dismiss results
document.addEventListener('click', e => {
if (!e.target.closest('.gs-wrap')) gsResults.classList.remove('show');
});
async function showPanel(id) {
const panel = document.getElementById('panel');
const body = document.getElementById('panel-body');
body.innerHTML = '<p class="pn-empty">Loading…</p>';
panel.classList.add('show');
try {
const r = await fetch('/api/businesses/' + id).then(r => r.json());
const b = r.business;
const a = r.latest_audit;
const s = r.latest_score;
const websiteHtml = b.website
? `<a class="pn-website" href="${b.website}" target="_blank" rel="noopener">Visit website ↗</a>`
: '<p class="pn-empty" style="margin-top:12px">No website on record.</p>';
const shotUrl = a && a.screenshot_path ? '/' + a.screenshot_path : null;
const shotHtml = shotUrl
? `<a href="${b.website || '#'}" target="_blank" rel="noopener" style="display:block;margin-bottom:14px;border:1px solid var(--rule);overflow:hidden;line-height:0"><img src="${shotUrl}" alt="${b.name} front-page screenshot" style="width:100%;height:auto;display:block;transition:opacity 220ms ease" onload="this.style.opacity=1" onerror="this.style.display='none'"></a>`
: '';
const ownerLine = r.enrichment ? `<div class="pn-row"><span class="k">Ownership</span><span class="v metal">${r.enrichment.ownership_class}${r.enrichment.parent_brand ? ' · ' + r.enrichment.parent_brand : ''}</span></div>` : '';
// News rail — surfaces what was scraped from this business's own site.
const newsItems = r.news || [];
const newsStats = r.news_stats || { fresh_7d: 0, fresh_30d: 0, last_at: null };
// Recency lozenge in the panel header — visible at-a-glance signal of
// an active publisher. "Hot" if 3+ in 7 days, "Fresh" if any in 7 days,
// "Stale" if any in 30 days but none recent. No badge if all news older.
const recencyBadge = (() => {
if (newsStats.fresh_7d >= 3) {
return `<span style="display:inline-block;margin:0 0 0 8px;padding:2px 8px;font-size:9px;letter-spacing:.2em;text-transform:uppercase;color:#0a0a0c;background:var(--metal-glow);border-radius:2px" title="${newsStats.fresh_7d} articles in the last 7 days">🔥 Hot</span>`;
}
if (newsStats.fresh_7d >= 1) {
return `<span style="display:inline-block;margin:0 0 0 8px;padding:2px 8px;font-size:9px;letter-spacing:.2em;text-transform:uppercase;color:var(--metal);border:1px solid var(--metal)" title="${newsStats.fresh_7d} articles in the last 7 days">📰 Fresh</span>`;
}
if (newsStats.fresh_30d >= 1) {
return `<span style="display:inline-block;margin:0 0 0 8px;padding:2px 8px;font-size:9px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-mute);border:1px solid var(--rule)" title="${newsStats.fresh_30d} articles in the last 30 days">Stale</span>`;
}
return '';
})();
// Defensive: source_url comes from scraped HTML on third-party sites.
// Validate scheme (http/https only — no javascript:/data:) and HTML-escape
// all attribute values before interpolating into href. Caught by codex-2way
// round-1 PROSECUTOR audit on tick 41's news rail commit.
const safeUrl = (raw) => {
try {
const u = new URL(String(raw || ''));
if (u.protocol !== 'http:' && u.protocol !== 'https:') return '#';
return u.href.replace(/[<>"' &]/g, c => ({'<':'%3C','>':'%3E','"':'%22',"'":'%27',' ':'%20','&':'&'}[c]));
} catch { return '#'; }
};
const escAttr = (s) => String(s || '').replace(/[<>"'&]/g, c => ({'<':'<','>':'>','"':'"',"'":''','&':'&'}[c]));
const newsBlock = newsItems.length ? (() => {
const items = newsItems.slice(0, 4).map(n => {
const dt = n.published_guess || n.fetched_at;
const dateStr = dt ? new Date(dt).toLocaleDateString('en-US', { month:'short', day:'numeric' }) : '';
const title = (n.title || '(untitled)').slice(0, 90);
const safeTitle = escAttr(title);
const href = safeUrl(n.source_url);
return `<a href="${href}" target="_blank" rel="noopener noreferrer" style="display:block;padding:6px 0;border-bottom:1px dotted var(--rule);text-decoration:none;color:var(--ink)"><div style="font-size:12px;line-height:1.35">${safeTitle}</div><div style="font-size:9px;color:var(--ink-mute);font-family:'JetBrains Mono',monospace;letter-spacing:.16em;margin-top:2px;text-transform:uppercase">${escAttr(dateStr)}</div></a>`;
}).join('');
return `
<div style="margin:14px 0 0;padding:10px 12px;background:rgba(184,153,104,0.08);border:1px solid var(--rule)">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px">
<div style="font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.28em;text-transform:uppercase;color:var(--metal)">📰 News from their site · ${newsItems.length}</div>
<a href="/business/${id}/news" style="font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--metal-glow);text-decoration:none;border-bottom:1px dotted var(--metal-glow)">all →</a>
</div>
${items}
</div>`;
})() : '';
const contactLines = (r.contacts || []).map(c => {
const platform = (c.notes === 'instagram' ? 'Instagram' :
c.notes === 'tiktok' ? 'TikTok' :
c.role === 'social_handle' ? 'Social' : c.role);
const handle = c.person_name || c.person_linkedin || '';
const valueHtml = c.source_url
? `<a href="${c.source_url}" target="_blank" rel="noopener" class="metal" style="text-decoration:none">${handle}</a>`
: handle;
return `<div class="pn-row"><span class="k">${platform}</span><span class="v">${valueHtml}</span></div>`;
}).join('');
body.innerHTML = `
${shotHtml}
<p class="pn-eyebrow">${b.category || 'Uncategorized'}</p>
<h3 class="pn-name">${b.name}${recencyBadge}${b.source !== 'la_btrc' && b.has_btrc_match === false ? ' <span style="display:inline-block;background:#fee2e2;border:1px solid #d33;color:#900;font-size:10px;font-weight:600;padding:2px 6px;border-radius:4px;margin-left:6px;vertical-align:middle">⚠ POSSIBLY NO BUSINESS LICENSE</span>' : ''}</h3>
<p class="pn-meta">${[b.address, b.city, b.zip].filter(Boolean).join(' · ')}</p>
${ownerLine}
<div class="pn-row"><span class="k">Block</span><span class="v">${b.corridor_block ?? '—'}</span></div>
<div class="pn-row"><span class="k">Phone</span><span class="v">${b.phone || '—'}</span></div>
<div class="pn-row"><span class="k">SEO score</span><span class="v metal">${s ? s.total : '— not scored'}</span></div>
<div class="pn-row"><span class="k">Tier</span><span class="v metal">${s ? s.tier : '—'}</span></div>
<div class="pn-row"><span class="k">Rank in category</span><span class="v">${s && s.category_rank ? `${s.category_rank} / ${s.category_n}` : '—'}</span></div>
<div class="pn-row"><span class="k">Last audit</span><span class="v">${a ? new Date(a.audited_at).toLocaleDateString() : '— not audited'}</span></div>
${contactLines}
${newsBlock}
${websiteHtml}
`;
} catch (e) {
body.innerHTML = '<p class="pn-empty">Failed to load.</p>';
}
}
async function loadCorridor() {
try {
const r = await fetch('/api/segments').then(r => r.json());
if (!r.rows || !r.rows.length) return;
// Build a single polyline through every segment endpoint.
const points = [];
for (const s of r.rows) {
if (!points.length) points.push([s.west_lat, s.west_lng]);
points.push([s.east_lat, s.east_lng]);
}
L.polyline(points, { color: '#b89968', weight: 3, opacity: 0.55, dashArray: null }).addTo(map);
L.polyline(points, { color: '#d4b683', weight: 1, opacity: 0.85 }).addTo(map);
} catch (e) { /* no data yet */ }
}
async function loadBuildStatus() {
const el = document.getElementById('build');
if (el) el.classList.add('refreshing');
try {
const r = await fetch('/api/build-status').then(r => r.json());
const c = r.counts || {};
const set = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = (v ?? 0).toLocaleString(); };
set('b-businesses', c.businesses);
set('b-with-site', c.with_site);
set('b-audited', c.audited);
set('b-scored', c.scored);
set('b-enriched', c.enriched);
set('b-owner', c.with_owner_contact);
const setNews = (id, v) => { const e = document.getElementById(id); if (e) { const a = e.querySelector('a'); if (a) a.textContent = (v ?? 0).toLocaleString(); else e.textContent = (v ?? 0).toLocaleString(); } };
setNews('b-news', c.news_total);
setNews('b-news-biz', c.news_biz);
setNews('b-news-7d', c.news_fresh_7d);
setNews('b-ideas', c.ideas_accepted);
setNews('b-ideas-rej', c.ideas_rejected);
// Fleet: pull Mac1/Mac2 state from /api/fleet (10s cache server-side)
try {
const fleet = await fetch('/api/fleet').then(r => r.json());
const fmtMac = (m, port) => {
if (!m || !m.ok) return `<a href="http://${port}" target="_blank" style="color:#c47b7b;border-bottom:1px dotted #c47b7b;text-decoration:none">${m?.label || port} · offline</a>`;
const model = m.ollama_models[0];
const modelStr = model ? `${model.name} ${model.pinned ? '⚓' : ''}` : 'idle';
const cpu = m.cpu.toFixed(0), ram = m.ram_pct.toFixed(0);
const dashUrl = port === '100.94.103.98:9930' ? 'http://100.94.103.98:9930' : 'http://localhost:9931';
return `<a href="${dashUrl}" target="_blank" style="color:inherit;text-decoration:none"><span style="color:var(--metal)">${escapeHtml(m.label)}</span> · <span style="color:var(--ink)">${escapeHtml(modelStr)}</span> · cpu <b style="color:var(--metal-glow)">${cpu}%</b> · ram <b style="color:var(--metal-glow)">${ram}%</b></a>`;
};
const m1El = document.getElementById('fleet-mac1');
const m2El = document.getElementById('fleet-mac2');
if (m1El) m1El.innerHTML = fmtMac(fleet.macs[0], '100.94.103.98:9930');
if (m2El) m2El.innerHTML = fmtMac(fleet.macs[1], 'localhost:9931');
} catch(e) { /* fleet bridge unavailable */ }
set('b-A', c.tier_a);
set('b-B', c.tier_b);
set('b-C', c.tier_c);
set('b-D', c.tier_d);
const ts = new Date(r.ts || Date.now());
document.getElementById('b-ts').textContent = ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch {}
if (el) el.classList.remove('refreshing');
}
loadStats();
loadCorridor();
loadBusinesses();
loadBuildStatus();
// Live refresh every 5s while ingest/crawl/score is running.
setInterval(loadBuildStatus, 5000);
// Voices of the boulevard — random sharp/exceptional headlines
async function loadVoices() {
try {
const r = await fetch('/api/quotes?limit=3&min_voice=3').then(r => r.json());
const rows = r.rows || [];
if (!rows.length) return;
const tease = document.getElementById('voices-tease');
const list = document.getElementById('voices-list');
list.innerHTML = rows.map(q => {
const isExc = q.headline_voice === 4;
const tag = q.headline_voice_label;
const tagColor = isExc ? 'var(--metal-glow)' : 'var(--metal)';
const safeH = (q.headline || '').replace(/[<>]/g, '');
const safeN = (q.name || '').replace(/[<>]/g, '');
return `<div style="display:block">
<div style="font-size:8px;letter-spacing:.26em;text-transform:uppercase;color:${tagColor};margin-bottom:3px">${tag}</div>
<div style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:${isExc?17:15}px;line-height:1.25;color:var(--ink);margin-bottom:2px">${safeH}</div>
<div style="font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute)">— ${safeN}${q.city ? ' · ' + q.city : ''}</div>
</div>`;
}).join('');
tease.style.display = '';
} catch {}
}
loadVoices();
document.getElementById('voices-shuffle')?.addEventListener('click', loadVoices);
</script>
</body>
</html>