← back to Commercialrealestate
CRCP: firecities.crcp hub — 2025 burn-zone cities (Eaton+Palisades) grouped by incident
9cfd192bd75bfaafafb5834b3a0400c40e400c58 · 2026-08-20 13:37:37 -0700 · Steve Abrams
Files touched
M public/crcp-cities.jsA public/firecities.htmlM scripts/serve.js
Diff
commit 9cfd192bd75bfaafafb5834b3a0400c40e400c58
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Aug 20 13:37:37 2026 -0700
CRCP: firecities.crcp hub — 2025 burn-zone cities (Eaton+Palisades) grouped by incident
---
public/crcp-cities.js | 17 +++++--
public/firecities.html | 135 +++++++++++++++++++++++++++++++++++++++++++++++++
scripts/serve.js | 12 +++--
3 files changed, 155 insertions(+), 9 deletions(-)
diff --git a/public/crcp-cities.js b/public/crcp-cities.js
index e211b2b..09382b3 100644
--- a/public/crcp-cities.js
+++ b/public/crcp-cities.js
@@ -18,17 +18,26 @@
"pasadena", "paynescreek", "redding", "sanjuancapistrano", "santamonica", "shermanoaks", "sierramadre", "somerset",
"somis", "southlaketahoe", "studiocity", "topanga", "twinbridges", "ukiah", "weed", "woffordheights", "wrightwood"];
- // Hosts that are NOT a city (serve the app unscoped, or the hub). label-0 of these -> no scope.
- var RESERVED = { crcp: 1, recities: 1, www: 1, localhost: 1, "127": 1 };
+ // Hosts that are NOT a city (serve the app unscoped, or a hub). label-0 of these -> no scope.
+ var RESERVED = { crcp: 1, recities: 1, firecities: 1, www: 1, localhost: 1, "127": 1 };
+
+ // Fire-affected burn-zone cities (2025 LA firestorm) — the firecities.crcp hub scopes to these.
+ // fire = the incident label shown in the hub. Only slugs that are also real fleet cities.
+ var FIRE = {
+ altadena: 'Eaton Fire', pasadena: 'Eaton Fire', sierramadre: 'Eaton Fire',
+ pacificpalisades: 'Palisades Fire', malibu: 'Palisades Fire', topanga: 'Palisades Fire',
+ santamonica: 'Palisades Fire'
+ };
function title(s) { return s.replace(/(^|\s)\S/g, function (c) { return c.toUpperCase(); }); }
function norm(s) { return String(s == null ? "" : s).toLowerCase().replace(/[^a-z]/g, ""); }
var CITIES = SLUGS.map(function (slug) {
var label = LABELS[slug] || title(slug);
- return { slug: slug, label: label, host: slug + ".crcp.agentabrams.com", norm: norm(label) };
+ return { slug: slug, label: label, host: slug + ".crcp.agentabrams.com", norm: norm(label), fire: FIRE[slug] || null };
});
var BY_SLUG = {}; CITIES.forEach(function (c) { BY_SLUG[c.slug] = c; });
+ var FIRE_CITIES = CITIES.filter(function (c) { return c.fire; });
// Resolve the current host to a city object, or null when unscoped (base crcp / recities / ip / localhost).
function cityFromHost(host) {
@@ -40,7 +49,7 @@
function sameCity(cityStr, cityObj) { return !!cityObj && norm(cityStr) === cityObj.norm; }
window.CRCP_CITIES = {
- LABELS: LABELS, SLUGS: SLUGS, CITIES: CITIES, BY_SLUG: BY_SLUG,
+ LABELS: LABELS, SLUGS: SLUGS, CITIES: CITIES, BY_SLUG: BY_SLUG, FIRE: FIRE, FIRE_CITIES: FIRE_CITIES,
cityFromHost: cityFromHost, sameCity: sameCity, norm: norm, title: title
};
})();
diff --git a/public/firecities.html b/public/firecities.html
new file mode 100644
index 0000000..7ac5b03
--- /dev/null
+++ b/public/firecities.html
@@ -0,0 +1,135 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CRCP · Fire Cities</title>
+<style>
+ :root{
+ --bg:#160f0d; --panel:#221513; --panel2:#2c1a16; --edge:#43261f;
+ --ink:#f3e9e6; --mut:#c39a8e; --accent:#ff7a45; --accent2:#c2410c; --ember:#ff5722;
+ }
+ *{box-sizing:border-box}
+ body{margin:0;background:linear-gradient(180deg,#160f0d,#1c1210);color:var(--ink);
+ font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;min-height:100vh}
+ .wrap{max-width:1080px;margin:0 auto;padding:40px 20px 80px}
+ header.top{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:6px}
+ header.top h1{font-size:26px;font-weight:700;margin:0;letter-spacing:.2px}
+ header.top h1 .fl{color:var(--ember)}
+ header.top .tag{color:var(--mut);font-size:14px}
+ .sub{color:var(--mut);margin:0 0 24px;max-width:70ch}
+ .panel{background:var(--panel);border:1px solid var(--edge);border-radius:14px;padding:22px;box-shadow:0 8px 30px rgba(0,0,0,.35)}
+ .pickrow{display:flex;gap:12px;flex-wrap:wrap;align-items:center}
+ label.lbl{color:var(--mut);font-size:13px;text-transform:uppercase;letter-spacing:.6px;display:block;margin-bottom:8px}
+ select,button{font:inherit}
+ select#city{flex:1 1 320px;min-width:240px;background:var(--panel2);color:var(--ink);border:1px solid var(--edge);
+ border-radius:10px;padding:13px 14px;appearance:none;
+ background-image:linear-gradient(45deg,transparent 50%,var(--mut) 50%),linear-gradient(135deg,var(--mut) 50%,transparent 50%);
+ background-position:calc(100% - 20px) 20px,calc(100% - 14px) 20px;background-size:6px 6px,6px 6px;background-repeat:no-repeat}
+ button.go{background:var(--accent2);color:#fff;border:1px solid var(--accent);border-radius:10px;padding:13px 22px;cursor:pointer;font-weight:600}
+ button.go:hover{background:var(--accent)}
+ .count{color:var(--mut);font-size:13px;margin:16px 0 6px}
+ .group{margin-top:22px}
+ .group h2{font-size:13px;text-transform:uppercase;letter-spacing:.8px;color:var(--ember);margin:0 0 10px;display:flex;align-items:center;gap:8px}
+ .group h2 .dot{width:8px;height:8px;border-radius:50%;background:var(--ember);box-shadow:0 0 8px var(--ember)}
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:10px}
+ a.city{display:flex;justify-content:space-between;align-items:center;gap:10px;text-decoration:none;color:var(--ink);
+ background:var(--panel2);border:1px solid var(--edge);border-radius:10px;padding:12px 14px;transition:.12s}
+ a.city:hover{border-color:var(--accent);background:#33201a;transform:translateY(-1px)}
+ a.city.empty{opacity:.55}
+ a.city .nm{font-weight:600}
+ a.city .host{display:block;color:var(--mut);font-size:12px;margin-top:2px;font-weight:400}
+ a.city .n{flex:0 0 auto;font-size:12px;color:var(--mut);background:#1c1210;border:1px solid var(--edge);border-radius:20px;padding:2px 9px}
+ a.city .n.has{color:#ffcaa8;border-color:var(--accent2)}
+ .links{margin-top:26px;display:flex;gap:12px;flex-wrap:wrap}
+ .links a{color:#ffb38a;text-decoration:none;border:1px solid var(--edge);border-radius:8px;padding:6px 12px;font-size:13px}
+ .links a:hover{border-color:var(--accent)}
+ footer{color:var(--mut);font-size:12px;margin-top:30px}
+</style>
+</head>
+<body>
+<div class="wrap">
+ <header class="top">
+ <h1><span class="fl">🔥</span> CRCP · Fire Cities</h1>
+ <span class="tag">Commercial real estate in the 2025 LA burn zones</span>
+ </header>
+ <p class="sub">The fire-affected markets from the January 2025 Eaton and Palisades fires. Choose a
+ city to open its scoped CRE dashboard — rebuild activity, listings, and broker coverage.</p>
+
+ <div class="panel">
+ <label class="lbl" for="city">Jump to a burn-zone city</label>
+ <div class="pickrow">
+ <select id="city" aria-label="Select a fire-affected city"></select>
+ <button class="go" id="go">Open →</button>
+ </div>
+ <div class="count" id="count"></div>
+ <div id="groups"></div>
+ <div class="links">
+ <a href="https://recities.crcp.agentabrams.com/">← All cities (recities)</a>
+ <a href="https://crcp.agentabrams.com/">All markets (base CRCP)</a>
+ </div>
+ </div>
+ <footer id="foot"></footer>
+</div>
+
+<script src="/crcp-cities.js"></script>
+<script>
+var FIRE = window.CRCP_CITIES.FIRE_CITIES.slice(); // burn-zone cities from the shared module
+var sameCity = window.CRCP_CITIES.sameCity;
+var counts = {}, loaded = false;
+var urlOf = function (c) { return "https://" + c.host + "/"; };
+
+var sel = document.getElementById('city'), groups = document.getElementById('groups'),
+ count = document.getElementById('count'), foot = document.getElementById('foot');
+
+fetch('/data/ranked.json').then(function (r) { return r.json(); }).then(function (d) {
+ var arr = (d && d.ranked) || [];
+ FIRE.forEach(function (c) { counts[c.slug] = arr.filter(function (p) { return sameCity(p.city, c); }).length; });
+ loaded = true; render();
+}).catch(function () { loaded = true; render(); });
+
+function ordered() {
+ return FIRE.slice().sort(function (a, b) {
+ var na = counts[a.slug] || 0, nb = counts[b.slug] || 0;
+ if (nb !== na) return nb - na;
+ return a.label.localeCompare(b.label);
+ });
+}
+function fillSelect() {
+ sel.innerHTML = '';
+ ordered().forEach(function (c) {
+ var o = document.createElement('option'); o.value = c.slug;
+ o.textContent = c.label + (loaded ? ' (' + (counts[c.slug] || 0) + ')' : '');
+ sel.appendChild(o);
+ });
+}
+function card(c) {
+ var n = counts[c.slug] || 0;
+ return '<a class="city' + (loaded && n === 0 ? ' empty' : '') + '" href="' + urlOf(c) + '">' +
+ '<span><span class="nm">' + c.label + '</span><span class="host">' + c.host + '</span></span>' +
+ '<span class="n' + (n ? ' has' : '') + '">' + (loaded ? n : '·') + '</span></a>';
+}
+function render() {
+ fillSelect();
+ // group by incident (fire label)
+ var byFire = {};
+ ordered().forEach(function (c) { (byFire[c.fire] = byFire[c.fire] || []).push(c); });
+ var html = '';
+ Object.keys(byFire).forEach(function (fire) {
+ var tot = byFire[fire].reduce(function (s, c) { return s + (counts[c.slug] || 0); }, 0);
+ html += '<div class="group"><h2><span class="dot"></span>' + fire +
+ (loaded ? ' · ' + tot + ' listings' : '') + '</h2><div class="grid">' +
+ byFire[fire].map(card).join('') + '</div></div>';
+ });
+ groups.innerHTML = html;
+ var total = FIRE.reduce(function (s, c) { return s + (counts[c.slug] || 0); }, 0);
+ count.textContent = loaded ? (FIRE.length + ' burn-zone cities · ' + total + ' listings') : (FIRE.length + ' burn-zone cities');
+ foot.textContent = 'CRCP · firecities.crcp.agentabrams.com';
+}
+function open() { var c = FIRE.find(function (x) { return x.slug === sel.value; }); if (c) window.location.href = urlOf(c); }
+document.getElementById('go').addEventListener('click', open);
+sel.addEventListener('keydown', function (e) { if (e.key === 'Enter') open(); });
+render();
+</script>
+</body>
+</html>
diff --git a/scripts/serve.js b/scripts/serve.js
index 1177149..baedb92 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -71,10 +71,12 @@ const BADGE_TAG = '<script src="/user-badge.js" defer></script>';
// window.fetch and filter ranked.json to <city>.crcp.agentabrams.com. Unscoped hosts are a no-op.
// (sync scripts, order matters: cities table then the scoper.)
const SCOPE_HEAD = '<script src="/crcp-cities.js"></script><script src="/crcp-scope.js"></script>';
+// Hub hosts serve a launcher page at '/' instead of the app: recities. = all cities,
+// firecities. = 2025 burn-zone cities. Every other host gets the app (index.html).
+const HUB_PAGES = { 'recities.crcp.agentabrams.com': 'recities.html', 'firecities.crcp.agentabrams.com': 'firecities.html' };
app.get(/(?:^\/$|\.html$)/, (req, res, next) => {
- // recities.crcp.agentabrams.com serves the city-launcher hub at '/'; every other host gets the app.
- const isRecities = (req.hostname || '').toLowerCase() === 'recities.crcp.agentabrams.com';
- const rel = req.path === '/' ? (isRecities ? 'recities.html' : 'index.html') : req.path.replace(/^\/+/, '');
+ const hub = HUB_PAGES[(req.hostname || '').toLowerCase()];
+ const rel = req.path === '/' ? (hub || 'index.html') : req.path.replace(/^\/+/, '');
if (rel === 'login.html') return next(); // sign-in page stays badge-free
const file = path.join(PUB_DIR, rel);
if (!file.startsWith(PUB_DIR + path.sep)) return next(); // path-traversal guard
@@ -83,8 +85,8 @@ app.get(/(?:^\/$|\.html$)/, (req, res, next) => {
let out = html.includes('/user-badge.js') ? html
: html.includes('</body>') ? html.replace(/<\/body>/i, BADGE_TAG + '</body>')
: html + BADGE_TAG;
- // recities.html loads crcp-cities.js itself; the scoper is only meaningful on the app pages.
- if (rel !== 'recities.html' && !out.includes('/crcp-scope.js')) {
+ // hub pages load crcp-cities.js themselves; the scoper is only meaningful on the app pages.
+ if (rel !== 'recities.html' && rel !== 'firecities.html' && !out.includes('/crcp-scope.js')) {
out = out.includes('</head>') ? out.replace(/<\/head>/i, SCOPE_HEAD + '</head>')
: (out.includes('<body') ? out.replace(/<body[^>]*>/i, '$&' + SCOPE_HEAD) : SCOPE_HEAD + out);
}
← 8376503 auto-data-snapshot: 2026-08-20T13:34:43 (1 data files) — scr
·
back to Commercialrealestate
·
chore: add broker-name normalizer + version bump (session cl f30d3c7 →