Every building in Los Angeles
:root { –bg: #0b0e14; –bg-rgb: 11,14,20; –ink: #f2efe6; –ink-2: #e2dfd6; –mut: #a7adb7; –mut-2: #8b929d; –dim: #6c737e; –faint: #5f6771; –line: #232936; –line-2: #2c3340; –rust: #a2472e; –btn: #e8e6df; –btn-fg: #0b0e14; –tip-bg: rgba(17,21,29,.96); –wm: #c8c3b6; –lg: #7d858f; –lg2: #9aa1ad; –tick: #3a414d; –display: ‘Big Shoulders Display’, ‘Arial Narrow’, Impact, sans-serif; –sans: ‘Instrument Sans’, system-ui, -apple-system, sans-serif; } /* Bright = a daytime map: paper ground, blue water, tan neighbors, dark type */ body.bright { –bg: #ece7db; –bg-rgb: 236,231,219; –ink: #1b2030; –ink-2: #2a3040; –mut: #4e5665; –mut-2: #5f6878; –dim: #7c8492; –faint: #8b9199; –line: #cfc9bc; –line-2: #bfb8aa; –btn: #1b2030; –btn-fg: #ece7db; –tip-bg: rgba(255,252,245,.97); –wm: #3a3f4a; –lg: #6a7280; –lg2: #5f6878; –tick: #b3ac9e; } html, body { margin: 0; height: 100%; background: var(–bg); color: var(–ink); font-family: var(–sans); overflow: hidden; } [hidden] { display: none !important; } #map { position: absolute; inset: 0; background: var(–bg); } .maplibregl-ctrl-attrib { display: none; } .maplibregl-canvas { outline: none; } a { color: #e8a26d; text-decoration: none; } a:hover { color: #ffcf5c; }
.ui { position: absolute; pointer-events: none; } .ui > * { pointer-events: auto; }
/* scrims */ .scrim-left { position: absolute; left: 0; top: 0; bottom: 0; width: 44%; max-width: 640px; pointer-events: none; background: linear-gradient(90deg, rgba(var(–bg-rgb),.92) 0%, rgba(var(–bg-rgb),.55) 55%, rgba(var(–bg-rgb),0) 100%); transition: opacity .8s; } .scrim-bottom { position: absolute; left: 0; right: 0; bottom: 0; height: 240px; pointer-events: none; background: linear-gradient(0deg, rgba(var(–bg-rgb),.95) 0%, rgba(var(–bg-rgb),0) 100%); } body.explore .scrim-left { opacity: 0; } .scrim-top { position: absolute; left: 0; right: 0; top: 0; height: 150px; pointer-events: none; background: linear-gradient(180deg, rgba(var(–bg-rgb),.9) 0%, rgba(var(–bg-rgb),0) 100%); }
.wordmark { top: 28px; left: 36px; display: flex; align-items: center; gap: 12px; } .wordmark .sq { width: 10px; height: 10px; background: var(–rust); } .wordmark b { font-size: 12px; letter-spacing: .22em; text-transform: uppercase; color: var(–wm); font-weight: 600; } .wordmark span { font-size: 12px; color: var(–faint); }
.legend { top: 28px; right: 36px; display: flex; flex-direction: column; gap: 8px; align-items: flex-end; } .legend .cap { font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(–lg); } .legend .row { display: flex; gap: 2px; } .legend .sw { display: flex; flex-direction: column; gap: 5px; align-items: center; width: 44px; } .legend .sw i { display: block; height: 6px; width: 44px; border-radius: 1px; } .legend .sw span { font-size: 10px; letter-spacing: .04em; color: var(–lg2); } .legend .bar { height: 6px; width: 264px; border-radius: 1px; background: linear-gradient(90deg,#1f2b47,#2f4f86,#3f7fb4,#5fb8d6,#c9e6df,#f6ecb0); } .legend .ends { display: flex; justify-content: space-between; width: 264px; font-size: 10px; color: var(–lg2); letter-spacing: .04em; margin-top: 5px; }
.title { left: 36px; top: 150px; display: flex; flex-direction: column; gap: 10px; max-width: 520px; transition: opacity .8s, transform .8s; } .title h1 { margin: 0; font-family: var(–display); font-weight: 700; font-size: 64px; line-height: .95; letter-spacing: -.01em; color: var(–ink); text-wrap: balance; } .title p { margin: 0; font-size: 16px; line-height: 1.5; color: var(–mut); max-width: 400px; } body.explore .title { opacity: 0; transform: translateY(-12px); pointer-events: none; }
.year { left: 36px; bottom: 132px; display: flex; flex-direction: column; gap: 2px; } .year .big { font-family: var(–display); font-weight: 500; font-size: 168px; line-height: .9; letter-spacing: -.02em; color: var(–ink); font-variant-numeric: tabular-nums; } .year .count { display: flex; align-items: baseline; gap: 10px; padding-left: 6px; } .year .count b { font-size: 24px; font-weight: 600; color: var(–ink); font-variant-numeric: tabular-nums; } .year .count span { font-size: 14px; color: var(–mut-2); letter-spacing: .02em; } body.explore .year { bottom: 176px; } body.explore .year .big { font-size: 96px; } body.explore .scrim-bottom { height: 320px; }
.caption { left: 44%; bottom: 150px; display: flex; flex-direction: column; gap: 6px; max-width: 360px; transition: opacity .5s; } .caption .era { font-size: 11px; letter-spacing: .14em; text-transform: uppercase; font-weight: 600; } .caption p { margin: 0; font-size: 18px; line-height: 1.35; color: var(–ink-2); text-wrap: pretty; } .caption.hidden { opacity: 0; }
.controls { left: 36px; right: 36px; bottom: 112px; display: flex; align-items: center; justify-content: space-between; gap: 10px; opacity: 0; transition: opacity .8s; } body.explore .controls { opacity: 1; } .controls .group { display: flex; align-items: center; gap: 10px; } .chip { padding: 9px 14px; border: 1px solid var(–line-2); border-radius: 999px; font: inherit; font-size: 12px; letter-spacing: .02em; color: var(–ink-2); background: rgba(var(–bg-rgb),.85); cursor: pointer; } .chip:hover { border-color: var(–mut-2); color: var(–ink); } .chip.on { background: var(–btn); color: var(–btn-fg); border-color: var(–btn); } .seg { display: flex; border: 1px solid var(–line-2); border-radius: 999px; overflow: hidden; } .seg button { padding: 9px 14px; border: 0; font: inherit; font-size: 12px; letter-spacing: .02em; color: var(–mut-2); background: rgba(var(–bg-rgb),.6); cursor: pointer; } .seg button.on { color: var(–btn-fg); background: var(–btn); } .vr { width: 1px; height: 24px; background: var(–line-2); margin: 0 6px; } .credit { font-size: 11px; color: var(–faint); letter-spacing: .02em; text-align: right; }
.timeline { left: 36px; right: 36px; bottom: 36px; display: flex; align-items: flex-end; gap: 24px; } .play { width: 52px; height: 52px; border-radius: 50%; border: 0; background: var(–btn); display: flex; align-items: center; justify-content: center; flex-shrink: 0; cursor: pointer; padding: 0; } .play svg { display: block; } .track { flex-grow: 1; position: relative; height: 44px; cursor: pointer; } .track .rail { position: absolute; left: 0; right: 0; top: 0; height: 2px; background: var(–line); } .track .fill { position: absolute; left: 0; top: 0; height: 2px; background: var(–btn); } .track .knob { position: absolute; top: -6px; width: 14px; height: 14px; margin-left: -7px; border-radius: 50%; background: var(–btn); box-shadow: 0 0 0 4px var(–bg); } .track .ticks { position: absolute; left: 0; right: 0; top: 8px; display: flex; justify-content: space-between; } .track .tick { display: flex; flex-direction: column; align-items: center; gap: 6px; width: 0; } .track .tick i { display: block; width: 1px; height: 5px; background: var(–tick); } .track .tick.major i { height: 10px; } .track .tick span { font-size: 10px; color: var(–dim); white-space: nowrap; }
.tip { position: absolute; display: none; flex-direction: column; gap: 6px; padding: 12px 14px; background: var(–tip-bg); border: 1px solid var(–line-2); border-radius: 4px; min-width: 200px; pointer-events: none; transform: translate(-50%, calc(-100% - 18px)); z-index: 5; } .tip .cap { font-size: 14px; font-weight: 600; color: var(–ink); letter-spacing: .01em; } .tip .cap.muted { font-weight: 400; color: var(–mut-2); font-style: italic; } .tip .foot { font-size: 10px; letter-spacing: .08em; color: var(–faint); font-variant-numeric: tabular-nums; } .tip .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px 16px; font-size: 13px; color: var(–ink); font-variant-numeric: tabular-nums; } .tip .grid span:nth-child(odd) { color: var(–mut-2); }
.toast { position: absolute; left: 50%; top: 24px; transform: translateX(-50%) translateY(-8px); padding: 10px 16px; border-radius: 999px; background: var(–btn); color: var(–btn-fg); font-size: 12px; letter-spacing: .02em; opacity: 0; transition: opacity .25s, transform .25s; pointer-events: none; z-index: 6; } .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } .tip .click { font-size: 10px; color: var(–faint); letter-spacing: .04em; } .hint { right: 36px; top: 88px; font-size: 11px; color: var(–faint); letter-spacing: .02em; text-align: right; line-height: 1.6; } .note { right: 36px; bottom: 100px; font-size: 11px; color: var(–faint); letter-spacing: .02em; text-align: right; } body.explore .note { display: none; }
@media (max-width: 900px) { .title { top: 84px; max-width: 70vw; } .title h1 { font-size: 40px; } .title p { display: none; } .year .big, body.explore .year .big { font-size: 84px; } .year, body.explore .year { bottom: 210px; left: 20px; } .year .count b { font-size: 18px; } .year .count span { font-size: 12px; } .caption { left: 20px; right: 20px; bottom: 92px; max-width: none; } .caption p { font-size: 14px; } .scrim-bottom, body.explore .scrim-bottom { height: 380px; } .legend { display: none; } .hint { display: none; } .note { display: none; } .controls { bottom: 96px; left: 20px; right: 20px; flex-wrap: wrap; } .controls .credit { display: none; } .timeline { left: 20px; right: 20px; bottom: 20px; gap: 14px; } .play { width: 44px; height: 44px; } .wordmark { left: 20px; top: 20px; } .scrim-left { width: 100%; max-width: none; } .tip { display: none !important; } }
if (/(^|[#&])b=dim(&|$)/.test(location.hash)) document.body.classList.remove(‘bright’);
ParcelscopeLos Angeles
Decade built
Height
1 storymedian 16 fttallest
drag to pan · right-drag or ⌃-drag to tilt
Every buildingin Los Angeles
One box per building standing today, appearing in the year it was built. What was torn down along the way isn’t here, so this is the surviving city, not the city as it was.
1880
0of today’s buildings already built
DecadeHeight
DimBright
LARIAC 2020 building outlines · LA County Assessor roll heights exaggerated when zoomed out
heights exaggerated when zoomed out
Building
Built Height Footprint Type
Copied
(async function () { const Y0 = 1880, Y1 = 2026, SPAN = Y1 - Y0; const STEP = 5; // years per layer bucket const DURATION_MS = 40000; // Where the page’s files live. Local dev serves everything next to index.html. In production the // bucket is exposed as a custom domain on parcelscope.net and a rewrite rule maps “/” to // /skyline/index.html, so on that host everything is under /skyline/ regardless of the URL shown. // A Pages preview (*.pages.dev) falls back to the rate-limited r2.dev hostname. const qs = new URLSearchParams(location.search); const BASE = qs.get(‘base’) || ( /.?parcelscope.net$/.test(location.hostname) ? location.origin + ‘/skyline/’ : /.pages.dev$/.test(location.hostname) ? ‘https://pub-8a47526a78d644688b6cd8d9884aad26.r2.dev/skyline/' : ‘./’); const TILES = qs.get(’tiles’) || BASE + ‘buildings.pmtiles’; const GRID = qs.get(‘grid’) || BASE + ‘grid.pmtiles’; const BASEMAP = qs.get(‘basemap’) || BASE + ‘basemap.pmtiles’; // Protomaps/OSM extract, water layer only // icons live next to the tiles, so the links are built from BASE rather than written statically in for (const [rel, href, type, sizes] of [[‘icon’, ‘icons/favicon.svg’, ‘image/svg+xml’], [‘icon’, ‘icons/icon-32.png’, ‘image/png’, ‘32x32’], [‘apple-touch-icon’, ‘icons/icon-180.png’, ‘image/png’, ‘180x180’]]) { const l = document.createElement(’link’); l.rel = rel; l.href = BASE + href; l.type = type; if (sizes) l.sizes = sizes; document.head.appendChild(l); }
const DECADES = [[1880,’#ffcf5c’],[1910,’#ffa64a’],[1920,’#ff7a45’],[1930,’#f26a6a’],[1940,’#e0679a’],[1950,’#bf74c9’], [1960,’#9484e0’],[1970,’#6a95ea’],[1980,’#4aa7e6’],[1990,’#3ab8d4’],[2000,’#3cc7b6’],[2010,’#5fd6a0’]]; const UNKNOWN = ‘#3a3f4a’; // Two themes. Dim is the night look: a void with only the city lit. Bright is a daytime map: // paper ground, blue water, tan for buildings outside the city, dark type. Toggle in the controls or b=bright in the hash. const THEMES = { dim: { bg: ‘#0b0e14’, water: ‘#111a28’, waterLine: ‘#152033’, ghost: ‘#141a26’, neighbor: ‘#1d232e’, unknown: ‘#3a3f4a’, hover: ‘#ffffff’, light: 0.45, hramp: [[0, ‘#1f2b47’], [16, ‘#2f4f86’], [27, ‘#3f7fb4’], [45, ‘#5fb8d6’], [100, ‘#c9e6df’], [400, ‘#f6ecb0’]] }, bright: { bg: ‘#ece7db’, water: ‘#9cc4e4’, waterLine: ‘#7fb0d8’, ghost: ‘#dad3c4’, neighbor: ‘#b89b7c’, unknown: ‘#8d8a84’, hover: ‘#111111’, light: 0.55, hramp: [[0, ‘#d3e2ee’], [16, ‘#98bfdc’], [27, ‘#5d92c2’], [45, ‘#33629d’], [100, ‘#1e3f70’], [400, ‘#0f2347’]] }, }; let bright = !/(^|[#&])b=dim(&|$)/.test(location.hash); // daytime map is the default; b=dim in the hash for the night look const T = () => THEMES[bright ? ‘bright’ : ‘dim’]; const tone = hex => hex; // decade colors are shared by both themes const ERAS = [ [1880, 1909, ‘Land boom’, ‘#ffcf5c’, ‘The 1880s rail boom seeds the first neighborhoods. A few thousand of those buildings are still standing.’], [1910, 1929, ‘The Twenties’, ‘#ff7a45’, ‘Bungalows and courtyard apartments fill the basin. Over 230,000 buildings still in use date from this one decade.’], [1930, 1949, ‘War and after’, ‘#e0679a’, ‘Defense work brings a wave of housing. Nearly 190,000 surviving buildings went up in the 1940s.’], [1950, 1959, ‘Postwar’, ‘#bf74c9’, ‘The biggest decade on the map. The Valley fills in tract by tract with single-story homes.’], [1960, 1979, ‘Dingbats and towers’, ‘#9484e0’, ‘Apartments replace houses along the boulevards. Downtown, freed of its 150-foot height limit in 1957, gets its first towers.’], [1980, 2026, ‘Slowdown’, ‘#4aa7e6’, ‘New construction slows sharply. What gets built concentrates downtown, in Hollywood, and along Wilshire.’], ]; const PRESETS = [ [‘Downtown’, { center: [-118.2530, 34.0490], zoom: 14.3, pitch: 62, bearing: -25 }], [‘Wilshire corridor’,{ center: [-118.3200, 34.0620], zoom: 13.4, pitch: 58, bearing: 70 }], [‘Century City’, { center: [-118.4150, 34.0570], zoom: 14.2, pitch: 60, bearing: -40 }], [‘Venice’, { center: [-118.4700, 33.9900], zoom: 14.0, pitch: 55, bearing: 25 }], [‘The Valley’, { center: [-118.4500, 34.1900], zoom: 11.6, pitch: 55, bearing: -10 }], ]; const HOME = { center: [-118.33, 34.04], zoom: 10.85, pitch: 56, bearing: -18 };
// —- legend + ticks
const sw = document.getElementById(‘decade-swatches’);
DECADES.forEach(([d, c]) => { const el = document.createElement(‘div’); el.className = ‘sw’;
el.innerHTML = ${d}; sw.appendChild(el); });
const ticks = document.getElementById(’ticks’);
for (let y = Y0; y ${y % 20 === 0 ? y : ‘’}`; ticks.appendChild(t); }
const years = await fetch(BASE + ‘years.json’).then(r => r.json()); const countAt = y => years.cum[Math.max(0, Math.min(SPAN, Math.floor(y) - Y0))];
// —- layers: one fill-extrusion layer per STEP-year bucket, so the slider only touches opacity (cheap) // yr may be absent or null (2% of buildings): test its type, never compare null to a number const HAS_YR = [’==’, [’typeof’, [‘get’, ‘yr’]], ’number’]; const NO_YR = [’!=’, [’typeof’, [‘get’, ‘yr’]], ’number’]; const buckets = []; for (let s = Y0; s =’, [‘get’, ‘yr’], s], [’ { const step = [‘step’, [‘get’, ‘yr’], tone(DECADES[0][1])]; DECADES.slice(1).forEach(([d, c]) => step.push(d, tone(c))); return [‘case’, [‘boolean’, [‘feature-state’, ‘hover’], false], T().hover, NO_YR, T().unknown, step]; }; const heightColor = () => [‘case’, [‘boolean’, [‘feature-state’, ‘hover’], false], T().hover, [‘interpolate’, [’linear’], [‘get’, ‘h’], …T().hramp.flatMap(([h, c]) => [h, c])]]; // real height in meters at z15+, exaggerated when zoomed out so the city reads as a skyline const heightExpr = [‘interpolate’, [’linear’], [‘zoom’], 9, [’’, [‘get’, ‘h’], 0.3048 * 40], 11, [’’, [‘get’, ‘h’], 0.3048 * 12], 13, [’’, [‘get’, ‘h’], 0.3048 * 3], 15, [’’, [‘get’, ‘h’], 0.3048]];
const protocol = new pmtiles.Protocol(); maplibregl.addProtocol(‘pmtiles’, protocol.tile); const tilesUrl = new URL(TILES, location.href).href; const gridUrl = new URL(GRID, location.href).href;
// Two sources: real footprints from z12.5 up; a 100 m grid (count, median year, p90 height) below that,
// because a citywide view cannot carry 1.1M polygons and thinning them makes the city look empty.
const SPLIT = 12.5;
const SOURCES = [
{ key: ‘g’, source: ‘g’, layer: ‘grid’, minzoom: 0, maxzoom: SPLIT },
{ key: ‘b’, source: ‘b’, layer: ‘buildings’, minzoom: SPLIT, maxzoom: 24 },
];
const layers = []; // {id, bucket, src}
for (const s of SOURCES) for (const b of buckets) layers.push({ id: ${s.key}-${b.id}, bucket: b, src: s });
const map = new maplibregl.Map({
container: ‘map’, hash: ‘map’, attributionControl: false, antialias: false, maxPitch: 70, minZoom: 9.5, maxZoom: 17,
style: { version: 8, sources: {
b: { type: ‘vector’, url: ‘pmtiles://’ + tilesUrl, promoteId: ‘id’ },
g: { type: ‘vector’, url: ‘pmtiles://’ + gridUrl },
n: { type: ‘vector’, url: ‘pmtiles://’ + BASE + ’neighbors.pmtiles’ },
ng: { type: ‘vector’, url: ‘pmtiles://’ + BASE + ’neighbors_grid.pmtiles’ },
base: { type: ‘vector’, url: ‘pmtiles://’ + new URL(BASEMAP, location.href).href } },
layers: [
{ id: ‘bg’, type: ‘background’, paint: { ‘background-color’: T().bg } },
// the rest of the county: no year built, so during the time-lapse it is land — flat pale footprints,
// the same treatment the city’s own not-yet-built structures get. The extrusions rise only once the
// timeline reaches today or the viewer starts exploring (showNeighbors). Grid below z13, footprints above.
{ id: ’ng-ghost’, type: ‘fill’, source: ’ng’, ‘source-layer’: ’ngrid’, maxzoom: 13, paint: { ‘fill-color’: T().ghost } },
{ id: ’n-ghost’, type: ‘fill’, source: ’n’, ‘source-layer’: ’nbuildings’, minzoom: 13, paint: { ‘fill-color’: T().ghost } },
{ id: ’ng-neighbors’, type: ‘fill-extrusion’, source: ’ng’, ‘source-layer’: ’ngrid’, maxzoom: 13,
paint: { ‘fill-extrusion-color’: T().neighbor, ‘fill-extrusion-height’: heightExpr, ‘fill-extrusion-vertical-gradient’: true,
‘fill-extrusion-opacity’: 0, ‘fill-extrusion-opacity-transition’: { duration: 1500, delay: 0 } } },
{ id: ’n-neighbors’, type: ‘fill-extrusion’, source: ’n’, ‘source-layer’: ’nbuildings’, minzoom: 13,
paint: { ‘fill-extrusion-color’: T().neighbor, ‘fill-extrusion-height’: heightExpr, ‘fill-extrusion-vertical-gradient’: true,
‘fill-extrusion-opacity’: 0, ‘fill-extrusion-opacity-transition’: { duration: 1500, delay: 0 } } },
// water: ocean, harbor, reservoirs, the river channel — a shade lighter and bluer than the void
{ id: ‘water’, type: ‘fill’, source: ‘base’, ‘source-layer’: ‘water’, filter: [’==’, [‘geometry-type’], ‘Polygon’],
paint: { ‘fill-color’: T().water } },
{ id: ‘water-line’, type: ’line’, source: ‘base’, ‘source-layer’: ‘water’, filter: [’==’, [‘geometry-type’], ‘LineString’],
paint: { ’line-color’: T().waterLine, ’line-width’: [‘interpolate’, [’linear’], [‘zoom’], 10, 0.6, 14, 2] } },
…SOURCES.map(s => ({ id: ${s.key}-ghost, type: ‘fill’, source: s.source, ‘source-layer’: s.layer, minzoom: s.minzoom, maxzoom: s.maxzoom,
paint: { ‘fill-color’: T().ghost } })),
…layers.map(l => ({ id: l.id, type: ‘fill-extrusion’, source: l.src.source, ‘source-layer’: l.src.layer, filter: l.bucket.filter,
minzoom: l.src.minzoom, maxzoom: l.src.maxzoom,
paint: { ‘fill-extrusion-color’: decadeColor(), ‘fill-extrusion-height’: heightExpr, ‘fill-extrusion-base’: 0,
‘fill-extrusion-opacity’: 0, ‘fill-extrusion-opacity-transition’: { duration: 0, delay: 0 },
‘fill-extrusion-vertical-gradient’: true } })),
] },
…(location.hash.includes(‘map=’) ? {} : HOME),
});
map.dragRotate.enable(); map.touchZoomRotate.enableRotation();
map.keyboard.enable();
// —- state const hashParams = () => new URLSearchParams(location.hash.replace(/^#/, ‘’).split(’&’).filter(p => !p.startsWith(‘map=’)).join(’&’)); let mode = hashParams().get(’m’) === ‘height’ ? ‘height’ : ‘decade’; let year = parseFloat(hashParams().get(‘y’)); let playing = false, raf = null, t0 = 0, yStart = Y0, exploring = false; const startedMid = Number.isFinite(year); if (!startedMid) year = Y0;
const el = id => document.getElementById(id); const fmt = n => n.toLocaleString(’en-US’);
function applyYear(y) { year = Math.max(Y0, Math.min(Y1, y)); for (const l of layers) { const op = Math.max(0, Math.min(1, (year - l.bucket.start) / STEP)); const vis = op > 0 ? ‘visible’ : ’none’; if (l._vis !== vis) { map.setLayoutProperty(l.id, ‘visibility’, vis); l._vis = vis; } if (op > 0 && l._op !== op) { map.setPaintProperty(l.id, ‘fill-extrusion-opacity’, op); l._op = op; } } setNeighbors(year >= Y1); el(‘year’).textContent = Math.floor(year); el(‘count’).textContent = fmt(countAt(year)); const pct = (year - Y0) / SPAN * 100; el(‘fill’).style.width = pct + ‘%’; el(‘knob’).style.left = pct + ‘%’; const era = ERAS.find(e => year >= e[0] && year c).join(’,’)})`; } function setMode(m) { mode = m; applyColors(); el(‘mode-decade’).classList.toggle(‘on’, m === ‘decade’); el(‘mode-height’).classList.toggle(‘on’, m === ‘height’); el(’legend-decade’).hidden = m !== ‘decade’; el(’legend-height’).hidden = m !== ‘height’; writeHash(); } function setBright(b) { bright = b; applyColors(); el(’tone-dim’).classList.toggle(‘on’, !b); el(’tone-bright’).classList.toggle(‘on’, b); writeHash(); } el(’tone-dim’).addEventListener(‘click’, () => setBright(false)); el(’tone-bright’).addEventListener(‘click’, () => setBright(true));
// Neighbors have no year built, so they stand only when the timeline is at the present. At any // earlier year — animating or scrubbed — they are flat land, whatever mode the viewer is in. let neighborsUp = false; function setNeighbors(up) { if (neighborsUp === up) return; neighborsUp = up; for (const id of [’ng-neighbors’, ’n-neighbors’]) map.setPaintProperty(id, ‘fill-extrusion-opacity’, up ? 1 : 0); } function enterExplore() { exploring = true; document.body.classList.add(’explore’); el(‘caption’).classList.add(‘hidden’); }
function tick(now) { const y = yStart + (now - t0) / DURATION_MS * SPAN; if (y >= Y1) { applyYear(Y1); stop(); enterExplore(); return; } applyYear(y); raf = requestAnimationFrame(tick); } function play() { if (year >= Y1) year = Y0; playing = true; yStart = year; t0 = performance.now(); el(‘ico-play’).hidden = true; el(‘ico-pause’).hidden = false; raf = requestAnimationFrame(tick); } function stop() { playing = false; if (raf) cancelAnimationFrame(raf); raf = null; el(‘ico-play’).hidden = false; el(‘ico-pause’).hidden = true; writeHash(); } el(‘play’).addEventListener(‘click’, () => playing ? stop() : play());
// scrubbing const track = el(’track’); const yearFromEvent = e => { const r = track.getBoundingClientRect(); return Y0 + Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * SPAN; }; let scrubbing = false; track.addEventListener(‘pointerdown’, e => { scrubbing = true; stop(); enterExplore(); applyYear(yearFromEvent(e)); track.setPointerCapture(e.pointerId); }); track.addEventListener(‘pointermove’, e => { if (scrubbing) applyYear(yearFromEvent(e)); }); track.addEventListener(‘pointerup’, () => { scrubbing = false; writeHash(); });
// controls el(‘mode-decade’).addEventListener(‘click’, () => setMode(‘decade’)); el(‘mode-height’).addEventListener(‘click’, () => setMode(‘height’)); const presets = el(‘presets’); PRESETS.forEach(([name, cam]) => { const b = document.createElement(‘button’); b.className = ‘chip’; b.textContent = name; b.addEventListener(‘click’, () => { presets.querySelectorAll(’.chip’).forEach(c => c.classList.toggle(‘on’, c === b)); map.flyTo({ …cam, duration: 2600, essential: true }); }); presets.appendChild(b); }); const home = document.createElement(‘button’); home.className = ‘chip’; home.textContent = ‘Whole city’; home.addEventListener(‘click’, () => { presets.querySelectorAll(’.chip’).forEach(c => c.classList.remove(‘on’)); map.flyTo({ …HOME, duration: 2600, essential: true }); }); presets.prepend(home);
// hover
let hovered = null;
const tip = el(’tip’);
// Assessor use-type codes, in plain words. R-I is the roll’s term for rental property (duplexes, apartments).
const USE = { SFR: ‘Single-family home’, CND: ‘Condominium’, ‘R-I’: ‘Apartments / rental’, ‘C/I’: ‘Commercial or industrial’, OTH: ‘Other’, VAC: ‘Vacant parcel’ };
const SMALL = new Set([‘and’, ‘of’, ’the’, ‘de’, ’la’, ‘del’, ’los’, ’las’]);
const titleCase = s => s.toLowerCase().replace(/\b([a-z])(\w*)/g, (m, a, b, i) => (i > 0 && SMALL.has(m)) ? m : a.toUpperCase() + b)
.replace(/\b(\d+)(St|Nd|Rd|Th)\b/g, (m, n, suf) => n + suf.toLowerCase())
.replace(/\b(N|S|E|W|Ne|Nw|Se|Sw)\b/g, m => m.toUpperCase());
const hoverLayers = […layers.map(l => l.id), ’n-neighbors’, ’ng-neighbors’];
map.on(‘mousemove’, e => {
if (!exploring) return;
const f = map.queryRenderedFeatures(e.point, { layers: hoverLayers })[0];
const isGrid = f && f.source === ‘g’;
const isNeighbor = f && (f.source === ’n’ || f.source === ’ng’);
if (isNeighbor && !neighborsUp) { tip.style.display = ’none’; map.getCanvas().style.cursor = ‘’; return; }
if (hovered !== null && (!f || isGrid || isNeighbor || f.id !== hovered)) { map.setFeatureState({ source: ‘b’, sourceLayer: ‘buildings’, id: hovered }, { hover: false }); hovered = null; }
if (!f) { tip.style.display = ’none’; map.getCanvas().style.cursor = ‘’; return; }
const p = f.properties;
const cap = el(’tip-cap’);
if (isNeighbor) {
cap.textContent = ‘Outside Los Angeles city limits’; cap.classList.add(‘muted’);
el(’tip-yr-l’).textContent = f.source === ’ng’ ? ‘Buildings here’ : ‘Height’; el(’tip-yr’).textContent = f.source === ’ng’ ? fmt(p.n) : fmt(Math.round(p.h)) + ’ ft’;
el(’tip-h-l’).textContent = ‘’; el(’tip-h’).textContent = ‘’;
el(’tip-a-l’).textContent = ‘’; el(’tip-a’).textContent = ‘’;
el(’tip-u-l’).textContent = ‘’; el(’tip-u’).textContent = ‘’;
el(’tip-foot’).textContent = ’no year built: parcel data covers the city only’;
} else if (isGrid) {
cap.textContent = ${fmt(p.n)} building${p.n === 1 ? '' : 's'} in this 100 m block; cap.classList.remove(‘muted’);
el(’tip-yr-l’).textContent = ‘Median year’; el(’tip-yr’).textContent = p.yr ? p.yr : ‘unknown’;
el(’tip-h-l’).textContent = ‘Tallest’; el(’tip-h’).textContent = fmt(Math.round(p.h)) + ’ ft’;
el(’tip-a-l’).textContent = ‘’; el(’tip-a’).textContent = ‘’;
el(’tip-u-l’).textContent = ‘’; el(’tip-u’).textContent = ‘’;
el(’tip-foot’).textContent = ‘zoom in for individual buildings’;
} else {
if (hovered !== f.id) { hovered = f.id; map.setFeatureState({ source: ‘b’, sourceLayer: ‘buildings’, id: hovered }, { hover: true }); }
const addr = p.addr ? titleCase(p.addr) : ‘’;
cap.textContent = addr || ‘No street address on the roll’; cap.classList.toggle(‘muted’, !addr);
el(’tip-yr-l’).textContent = ‘Built’; el(’tip-yr’).textContent = p.yr ? p.yr : ‘unknown’;
el(’tip-h-l’).textContent = ‘Height’; el(’tip-h’).textContent = fmt(Math.round(p.h)) + ’ ft’;
el(’tip-a-l’).textContent = ‘Footprint’; el(’tip-a’).textContent = fmt(p.a) + ’ sq ft’;
el(’tip-u-l’).textContent = ‘Type’; el(’tip-u’).textContent = USE[p.use] || ‘—’;
el(’tip-foot’).textContent = p.ain ? ‘Parcel ’ + p.ain : ‘’;
}
el(’tip-click’).textContent = (!isGrid && !isNeighbor) ? ‘click to copy details’ : ‘’;
tip.style.display = ‘flex’; tip.style.left = e.point.x + ‘px’; tip.style.top = e.point.y + ‘px’;
map.getCanvas().style.cursor = ‘crosshair’;
});
map.on(‘mouseout’, () => { tip.style.display = ’none’; });
// Click a building: copy its details, the Assessor’s page for the parcel, and a link back to this view,
// so anyone can carry the record into their own search. Parcel facts only, no owner data.
let toastTimer = null;
function toast(msg) { const t = el(’toast’); t.textContent = msg; t.classList.add(‘show’); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove(‘show’), 1800); }
map.on(‘click’, e => {
if (!exploring) return;
const f = map.queryRenderedFeatures(e.point, { layers: layers.filter(l => l.src.key === ‘b’).map(l => l.id) })[0];
if (!f) return;
const p = f.properties;
const lines = [
p.addr ? titleCase(p.addr) + ‘, Los Angeles, CA’ : ‘No street address on the assessor roll’,
Built: ${p.yr || 'unknown'},
Height: ${fmt(Math.round(p.h))} ft (LARIAC 2020 lidar),
Footprint: ${fmt(p.a)} sq ft,
Use type: ${USE[p.use] || 'unknown'},
Assessor parcel (AIN): ${p.ain},
Assessor record: https://portal.assessor.lacounty.gov/parceldetail/${p.ain},
Map: ${location.href},
];
const text = lines.join(’\n’);
const done = () => toast(‘Copied building details’);
const fail = () => { const ta = document.createElement(’textarea’); ta.value = text; document.body.appendChild(ta); ta.select();
try { document.execCommand(‘copy’); done(); } catch { toast(‘Could not copy’); } ta.remove(); };
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, fail); else fail();
});
map.on(‘dragstart’, () => { if (!exploring && !playing) enterExplore(); });
// hash: keep maplibre’s map= and add y= and m=
function writeHash() {
const parts = location.hash.replace(/^#/, ‘’).split(’&’).filter(p => p && !p.startsWith(‘y=’) && !p.startsWith(’m=’) && !p.startsWith(‘b=’));
parts.push(‘y=’ + Math.round(year), ’m=’ + mode);
if (!bright) parts.push(‘b=dim’);
history.replaceState(null, ‘’, ‘#’ + parts.join(’&’));
}
map.on(‘moveend’, writeHash);
map.on(‘zoom’, () => { const z = map.getZoom(); const ex = z >= 15 ? 1 : z 1.05 ? heights ×${ex.toFixed(0)} : ‘heights to scale’; });
map.on(’load’, () => { el(’tone-dim’).classList.toggle(‘on’, !bright); el(’tone-bright’).classList.toggle(‘on’, bright); setMode(mode); applyYear(year); if (startedMid) { enterExplore(); } else { setTimeout(play, 1200); } map.fire(‘zoom’); }); })();