Skip to main content
The NeighborhoodLabels class renders neighborhood and borough names on the map with a level-of-detail (LOD) system that adapts to the current zoom level.

Architecture

Neighborhood labels are not managed by OpenSeadragon’s overlay system. Instead, a custom absolutely-positioned container handles all label rendering to avoid performance issues.

Why Avoid OSD Overlays?

// NeighborhoodLabels.ts:1-11
/**
 * NeighborhoodLabels — LOD neighborhood labels drawn on a plain div overlay.
 *
 * We intentionally avoid OSD's addOverlay() system, which wraps every element
 * in a wrapper div and calls drawHTML() every animation frame — clobbering
 * transforms and fighting us for control of element styles.
 *
 * Instead: one absolutely-positioned container div sits above the OSD canvas,
 * and we position each label manually using viewport.pixelFromPoint().
 * We hook OSD's 'update-viewport' event to reposition on every frame.
 */
OpenSeadragon’s overlay system is designed for infrequently-updated elements like image annotations. For dynamic labels that need precise control over positioning and visibility, manual management is more performant.

Three-Tier LOD System

Labels are shown in three tiers based on zoom level:
// NeighborhoodLabels.ts:12-16
/**
 * Zoom tiers:
 *   zoom < 1.5  → 5 borough labels
 *   zoom 1.5–4  → ~40 major neighborhoods
 *   zoom > 4    → all 197 NTAs
 */

Tier Logic

// NeighborhoodLabels.ts:121-140
private updateTier() {
  const zoom = this.viewer.viewport?.getZoom() ?? 1;
  let tier: 0 | 1 | 2;
  if (zoom < 1.5) tier = 0;
  else if (zoom < 4) tier = 1;
  else tier = 2;

  if (tier === this.currentTier) return;
  this.currentTier = tier;

  for (const l of this.labels) {
    const show =
      (tier === 0 && l.tier === 'borough') ||
      (tier === 1 && (l.tier === 'borough' || l.tier === 'major')) ||
      (tier === 2);
    l.el.style.display = show ? '' : 'none';
  }

  this.draw();
}
  • < 1.5: City-wide view — only borough names are readable
  • 1.5–4: District view — major neighborhoods (e.g. “Williamsburg”, “Astoria”) are visible
  • > 4: Block-level view — all 197 NTA (Neighborhood Tabulation Area) names can be shown without overlap
These thresholds were empirically tuned to avoid label clutter.

Label Data Sources

Borough Labels

Five manually-positioned borough centroids:
// NeighborhoodLabels.ts:22-28
const BOROUGH_LABELS = [
  { name: 'MANHATTAN',     lat: 40.7831, lng: -73.9712 },
  { name: 'BROOKLYN',      lat: 40.6501, lng: -73.9496 },
  { name: 'QUEENS',        lat: 40.7282, lng: -73.7949 },
  { name: 'BRONX',         lat: 40.8448, lng: -73.8648 },
  { name: 'STATEN ISLAND', lat: 40.6050, lng: -74.0800 },
];

Major Neighborhoods

A curated set of 40 prominent NTA codes:
// NeighborhoodLabels.ts:30-37
const MAJOR_NTA_CODES = new Set([
  'MN2501','MN1701','MN2301','MN2701','MN2001','MN3301','MN2401','MN1301',
  'MN0901','MN1101','MN1001','MN2101','MN2201','MN1601','MN1501',
  'BK0101','BK0901','BK7301','BK9101','BK4501','BK8801','BK6101','BK5501','BK7701','BK3101',
  'QN3101','QN2601','QN4901','QN6301','QN5301','QN7101','QN4101','QN5701',
  'BX0101','BX3101','BX6301','BX0901','BX5301',
  'SI0101','SI0501','SI2501',
]);

NTA Data

All 197 NTAs are loaded from a JSON file:
// NeighborhoodLabels.ts:20
import ntaData from './nta_centroids.json';
Each entry contains:
{
  code: "MN2501",
  name: "Midtown-Midtown South",
  lat: 40.7549,
  lng: -73.9840
}

Label Entry Interface

// NeighborhoodLabels.ts:39-45
interface LabelEntry {
  name: string;
  vpX: number;  // OSD viewport X (0–1 range using image width as unit)
  vpY: number;
  tier: 'borough' | 'major' | 'nta';
  el: HTMLSpanElement;
}

Initialization

Container Setup

// NeighborhoodLabels.ts:55-68
constructor(viewer: OpenSeadragon.Viewer) {
  this.viewer = viewer;

  // Create a container div that sits on top of the OSD canvas
  this.container = document.createElement('div');
  this.container.style.cssText = `
    position: absolute;
    inset: 0;
    pointer-events: none;
    overflow: hidden;
    z-index: 80;
  `;
  // Insert into OSD's element (the viewer container), above everything
  viewer.element.appendChild(this.container);

  this.buildLabels();
  // ...
}
pointer-events: none ensures labels don’t interfere with map interactions. Users can still pan and click through the label layer.

Building Labels

All labels are pre-created and attached to the DOM:
// NeighborhoodLabels.ts:94-112
private buildLabels() {
  for (const b of BOROUGH_LABELS) {
    const { vpX, vpY } = this.toVp(b.lat, b.lng);
    const el = this.makeEl(b.name, 'borough');
    this.labels.push({ name: b.name, vpX, vpY, tier: 'borough', el });
  }
  for (const nta of ntaData) {
    const { vpX, vpY } = this.toVp(nta.lat, nta.lng);
    const isMajor = MAJOR_NTA_CODES.has(nta.code);
    const tier = isMajor ? 'major' : 'nta';
    const el = this.makeEl(nta.name, tier);
    this.labels.push({ name: nta.name, vpX, vpY, tier, el });
  }
  // All labels start hidden
  for (const l of this.labels) {
    l.el.style.display = 'none';
    this.container.appendChild(l.el);
  }
}

Element Creation

// NeighborhoodLabels.ts:114-119
private makeEl(text: string, tier: 'borough' | 'major' | 'nta'): HTMLSpanElement {
  const el = document.createElement('span');
  el.className = `nta-label nta-label--${tier}`;
  el.textContent = text;
  return el;
}
CSS classes (.nta-label--borough, .nta-label--major, .nta-label--nta) control font size, weight, and styling.

Coordinate Conversion

Labels are positioned using the same lat/lng → viewport coordinate system as permits:
// NeighborhoodLabels.ts:86-92
private toVp(lat: number, lng: number) {
  const { x, y } = latlngToImagePx(lat, lng);
  return {
    vpX: x / IMAGE_DIMS.width,
    vpY: y / IMAGE_DIMS.width,
  };
}

Viewport-Synced Positioning

Labels are repositioned on every viewport update using pixelFromPoint:
// NeighborhoodLabels.ts:142-154
private draw() {
  if (!this.enabled) return;
  const viewport = this.viewer.viewport;
  if (!viewport) return;

  for (const l of this.labels) {
    if (l.el.style.display === 'none') continue;
    // Convert OSD viewport point → screen pixel
    const px = viewport.pixelFromPoint(new OpenSeadragon.Point(l.vpX, l.vpY), true);
    l.el.style.left = `${px.x}px`;
    l.el.style.top  = `${px.y}px`;
  }
}

Event Hooks

// NeighborhoodLabels.ts:72-75
viewer.addHandler('update-viewport', () => this.draw());
viewer.addHandler('zoom', () => this.updateTier());
viewer.addHandler('pan', () => this.draw());
1

update-viewport

Fires on every frame during pan/zoom animations — ensures smooth label movement
2

zoom

Triggers tier switching when zoom crosses a threshold (1.5 or 4)
3

pan

Redundant with update-viewport but ensures labels stay synced during manual drags

Performance Optimizations

Pre-Render All Elements

All 200+ labels are created once during initialization and toggled via display: none rather than being created/destroyed dynamically.

Visibility Culling

Only visible labels are repositioned:
// NeighborhoodLabels.ts:148
if (l.el.style.display === 'none') continue;

Tier Caching

Tier updates are skipped if the zoom hasn’t crossed a threshold:
// NeighborhoodLabels.ts:128
if (tier === this.currentTier) return;

Enable/Disable Toggle

// NeighborhoodLabels.ts:80-84
setEnabled(val: boolean) {
  this.enabled = val;
  this.container.style.display = val ? '' : 'none';
  if (val) { this.currentTier = -1; this.updateTier(); }
}
This method allows the parent component to hide all labels without destroying the system.

Cleanup

// NeighborhoodLabels.ts:156-159
destroy() {
  if (this.rafId !== null) cancelAnimationFrame(this.rafId);
  this.container.remove();
}
The destroy method is called when the OpenSeadragon viewer is unmounted:
// App.tsx:351-358
return () => {
  labelsRef.current?.destroy();
  labelsRef.current = null;
  heliActiveRef.current = false;
  if (heliRafRef.current !== null) { cancelAnimationFrame(heliRafRef.current); heliRafRef.current = null; }
  viewer.destroy();
  osdRef.current = null;
};

Integration with App

The label system is initialized when the DZI tiles load:
// App.tsx:342-349
viewer.addHandler('open', () => {
  setDziLoaded(true);
  labelsRef.current = new NeighborhoodLabels(viewer);
  // Start zoomed into Midtown Manhattan (Empire State Building area)
  viewer.viewport.panTo(new OpenSeadragon.Point(0.3637, 0.3509), true);
  viewer.viewport.zoomTo(window.innerWidth <= 768 ? 10 : 3.5, undefined, true);
});
The initial zoom level (3.5 on desktop, 10 on mobile) places the map in tier 1, showing major neighborhoods.

Build docs developers (and LLMs) love