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 ();
}
Why these specific zoom thresholds?
< 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 ());
update-viewport
Fires on every frame during pan/zoom animations — ensures smooth label movement
zoom
Triggers tier switching when zoom crosses a threshold (1.5 or 4)
pan
Redundant with update-viewport but ensures labels stay synced during manual drags
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.