Skip to content

3D extrusion on a 2D map: when a bar chart belongs on the geometry, not in a sidebar

7/8/2025UI/UX DesignGeospatial Urban Analytics Platform14 min read

title: "3D extrusion on a 2D map: when a bar chart belongs on the geometry, not in a sidebar" slug: 3d-extrusion-on-2d-map pillar: Engineering Quality angle: case-breakdown audience: CTOs, senior engineers, fellow practitioners stack: Next.js status: draft

3D extrusion on a 2D map: when a bar chart belongs on the geometry, not in a sidebar

A choropleth tells you which district is high. An extruded prism in the same place tells you exactly how high without leaving the map. The MapLibre fill-extrusion choice that earned the screen real estate.

The brief in one paragraph

The viewer had a polygon layer — call it census tracts, traffic analysis zones, or hex bins, it does not matter for this post. The polygons carry numeric properties: population, trip count, dwelling units, whatever the analyst loaded. The first version of the screen rendered a colored choropleth on the map and a paginated table in a side panel. Picking a metric repainted the polygons in a five-step gradient and refreshed a small donut chart in the panel showing min / mean / max. The analysts complained about the same thing every demo: to compare two districts, they had to look at the color of polygon A, hover to get its number in a tooltip, click polygon B, read its number, then mentally subtract. The cognitive cost of moving between the map and the sidebar was higher than the cost of doing the analysis. The fix was to put the bar chart on the geometry itself — extrude each polygon to a height proportional to its value. A choropleth tells you which district is high. A prism in the same place tells you how high without leaving the map.

Why the sidebar lost — and why this is not "more 3D for the sake of 3D"

The argument against 3D on a map is usually correct. Pitched views cost screen pixels (the back of the scene gets smaller), they break direct distance comparison, and they invite tilt-shift demo-ware that looks impressive once and gets disabled inside a week. So before any code was written, the question was: is the extra channel — height — worth the lost ergonomics?

For one specific question, yes. The analyst's job here is "which polygons stand out, and by how much, against their neighbors?" A choropleth answers the first half well and the second half badly. Color is a categorical channel that humans can resolve into maybe seven steps before banding breaks down. Height is a continuous channel that humans can resolve into "A is roughly twice B" at a glance. When the dataset has one dominant numeric field and the question is comparative magnitude — not classification, not pattern — height beats color.

The constraint that justifies the engineering: the analyst must be able to pick the field, scale it, see the prism update, and walk away with a screenshot in one minute. Anything slower than that and the sidebar wins on speed alone. That requirement is what shaped the rest of the code.

The architecture: MapLibre expressions, not three.js

The first instinct, especially in a Next.js codebase that already has React Three Fiber loaded elsewhere, is to draw 3D primitives in a separate canvas overlaid on the map. Do not do this if you can avoid it. Two canvases means two camera systems, two coordinate spaces, and two render loops, all of which have to be kept in sync when the user pans or pitches. The cost of that synchronization is large and the bugs are subtle (depth fighting, wrong-side culling, prisms drifting one frame behind the basemap).

MapLibre — which this viewer already uses for vector tile rendering — has a native layer type called fill-extrusion. It takes a polygon source and three paint properties that drive the height, base, and color of the resulting prism. Those paint properties are not numbers; they are MapLibre style expressions, which means they can reference feature properties directly. The height of each prism is computed inside the renderer, per-feature, on the GPU. No second canvas, no synchronization, no JavaScript loop per frame.

The shape of the configuration this drives is the contract between the UI panel and the renderer. In components/viewer/types/ExtrusionTypes.ts:

export interface ExtrusionConfig {
  id: string;
  layerId: string;
  fieldName: string;
  scaleFactor: number;
  unit: 'meters' | 'centimeters';
  maxHeight: number;
  baseHeight: number;
  colorField?: string;
  colorScheme?: 'single' | 'gradient' | 'categorical' | 'quartile';
  opacity: number;
  enabled: boolean;
  heatMapEnabled?: boolean;
  quartileColors?: string[];
}

Nothing here is novel; it is the smallest object that fully describes a prism field. The two fields that earn their keep are scaleFactor and maxHeight. The scale factor turns analyst-meaningful units (people per tract, trips per zone) into something that renders sensibly at city scale. The max height stops one outlier from making the rest of the map look flat.

Reading the data: what fields can we actually extrude?

The analyst does not type a field name. They pick from a dropdown that only contains numeric fields the layer actually has. Building that dropdown is the unglamorous half of the feature, and it is also where most "we tried this and it didn't work" stories come from.

The layer's feature properties are loosely typed — strings, numbers, booleans, sometimes strings that happen to parse as numbers. The introspection loop in components/viewer/utils/extrusionUtils.ts walks the first 100 features and collects every property that can be coerced into a finite number, then computes min / max / mean and quartiles for each:

sampleFeatures.forEach((feature, index) => {
  const properties = feature.properties || {};

  Object.entries(properties).forEach(([key, value]) => {
    let numericValue: number | null = null;

    if (typeof value === 'number') {
      numericValue = value;
    } else if (typeof value === 'string') {
      const parsed = parseFloat(value);
      if (!isNaN(parsed)) {
        numericValue = parsed;
      }
    } else if (typeof value === 'boolean') {
      numericValue = value ? 1 : 0;
    }

    if (numericValue !== null &&
        typeof numericValue === 'number' &&
        !isNaN(numericValue) &&
        isFinite(numericValue)) {
      if (!fieldMap.has(key)) fieldMap.set(key, []);
      fieldMap.get(key)!.push(numericValue);
    }
  });
});

Two design choices buried here. First, booleans are coerced to 0/1 — sometimes useful (a "has_metro_station" flag becomes a binary prism), sometimes nonsense, but cheaper to surface and let the analyst skip than to filter out. Second, the sample is capped at 100 features. The full layer can be a hundred thousand polygons. The dropdown does not need a perfect histogram; it needs to be populated in under a frame. Quartiles computed from a 100-feature sample are good enough to drive a color scheme; if the analyst wants more precision later, the panel recomputes on demand.

The result is a list of ExtrusionField objects with the statistics needed both for the dropdown and for downstream color binning:

const field: ExtrusionField = {
  name,
  type: values.every(v => Number.isInteger(v)) ? 'integer' : 'number',
  min: sortedValues[0],
  max: sortedValues[sortedValues.length - 1],
  mean: values.reduce((sum, val) => sum + val, 0) / values.length,
  sampleValues: sortedValues.slice(0, 100),
  quartiles
};

From field to prism: the expression that does the work

Once the analyst picks a field and a scale factor, every prism height in the layer is determined by a single MapLibre expression. The expression is not interpreted on the CPU per polygon; it is compiled into the layer's paint state and the renderer walks features against it. This is the load-bearing piece of the whole feature:

const unit = EXTRUSION_UNITS.find(u => u.name === config.unit);
const unitMultiplier = unit?.multiplier || 1;
const effectiveScale = config.scaleFactor * unitMultiplier;

const heightExpression = [
  'min',
  [
    'max',
    config.baseHeight,
    ['*', ['get', config.fieldName], effectiveScale]
  ],
  config.maxHeight
];

Read that bottom-up: ['get', config.fieldName] pulls the analyst's chosen property off each feature. Multiply by the effective scale (scale factor times the unit multiplier — meters is 1, centimeters is 0.01 so the same numeric input renders shorter). Floor with max(baseHeight, ...) so degenerate values (zero, negative) do not produce sunken polygons. Cap with min(..., maxHeight) so one outlier zone does not turn the city into a single skyscraper next to flat ground.

The same pattern handles color. When the analyst picks "quartile" coloring, the color expression is generated from the quartiles the introspector already computed:

if (config.colorScheme === 'quartile' && config.quartileColors && field?.quartiles) {
  const { q1, q2, q3 } = field.quartiles;

  colorExpression = [
    'case',
    ['<=', ['get', config.fieldName], q1], config.quartileColors[0],
    ['<=', ['get', config.fieldName], q2], config.quartileColors[1],
    ['<=', ['get', config.fieldName], q3], config.quartileColors[2],
    config.quartileColors[3]
  ];
}

The four-color scale (white through dark red, or white through dark blue) is intentional. With only height to disambiguate, color does not need to carry information about value — it just needs to make the boundaries readable when prisms overlap from the analyst's view angle. Quartile binning is enough.

Wiring the expression into the live map

The above produces an expressions object; it does not yet change the map. Apply happens in one call, with the small but important detail that the extrusion layer is a sibling of the original fill layer, not a replacement:

if (map.getLayer(extrusionLayerId)) {
  map.setPaintProperty(extrusionLayerId, 'fill-extrusion-height', expressions.height);
  map.setPaintProperty(extrusionLayerId, 'fill-extrusion-base', expressions.base);
  map.setPaintProperty(extrusionLayerId, 'fill-extrusion-color', expressions.color);
  map.setPaintProperty(extrusionLayerId, 'fill-extrusion-opacity', config.opacity);
  map.setLayoutProperty(extrusionLayerId, 'visibility', config.enabled ? 'visible' : 'none');
} else {
  const extrusionLayer: maplibregl.LayerSpecification = {
    id: extrusionLayerId,
    type: 'fill-extrusion',
    source: sourceId,
    ...(sourceLayer && { 'source-layer': sourceLayer }),
    paint: {
      'fill-extrusion-height': expressions.height,
      'fill-extrusion-base': expressions.base,
      'fill-extrusion-color': expressions.color,
      'fill-extrusion-opacity': config.opacity
    },
    metadata: {
      isExtrusionLayer: true,
      originalLayerId: config.layerId,
      extrusionConfig: config
    }
  };
  map.addLayer(extrusionLayer);
}

Adding the layer as a separate ${layerId}-extrusion instead of toggling the original layer's type buys two things. The original 2D fill stays available for unstyled overview, and the extrusion can be torn down independently — removeExtrusionFromLayer deletes the sibling layer and leaves the source data intact. The metadata block holds the config that produced the layer, so reopening the panel restores the analyst's choices instead of starting from defaults.

The other detail that matters for ergonomics: when extrusion is first applied, the camera pitches.

const currentPitch = globalMap.getPitch();
if (currentPitch < 1) {
  globalMap.easeTo({ pitch: 45, duration: 500 });
}

A pitch of zero with an extrusion layer renders nothing visible — the prisms exist but the camera looks straight down their tops. Forcing a 45-degree tilt the first time saves the analyst from "I applied extrusion and nothing happened, your software is broken" which is the kind of bug report that is correct in spirit and wrong in detail.

The hardest sub-problem: when to even bother

Most of the visible code here is mechanical. The interesting decision was not "how do we extrude?" — MapLibre answers that — but "when do we let the analyst do it?". The panel guards on three things before anything renders.

First, the layer has to have numeric fields. If analyzeLayerFields returns an empty list, the panel shows "No numeric fields found in this layer" instead of an empty dropdown. The check sounds trivial; it is the most common failure mode after a layer upload because users routinely load polygon files where every property is a string.

Second, the config has to validate. validateExtrusionConfig runs before any paint property is touched:

if (config.scaleFactor <= 0) {
  errors.push('Scale factor must be greater than 0');
}

if (config.maxHeight <= config.baseHeight) {
  errors.push('Maximum height must be greater than base height');
}

if (field && config.scaleFactor * field.max > config.maxHeight * 2) {
  warnings.push('Many features may be capped at maximum height');
}

The last branch is the one that earns the validator. An analyst who picks population (max value: 8000) with a scale factor of 1 and a max height of 500 will get a layer where two-thirds of the prisms are flat-topped at 500 because their scaled height exceeds the cap. The warning tells them to lower the scale factor before they spend ten minutes wondering why the map looks wrong.

Third, the panel persists state across opens. Reanalyzing a hundred-thousand-feature layer every time the user closes and reopens the panel is wasteful; caching the field list for five minutes on the map object's metadata keeps the UI responsive without leaking memory across layer swaps:

const stateKey = `extrusion_analysis_${layerId}`;
const savedState = globalMap._extrusionAnalysisCache.get(stateKey);

if (savedState && savedState.availableFields.length > 0) {
  const isRecent = (Date.now() - savedState.timestamp) < 5 * 60 * 1000;
  if (isRecent) {
    setAvailableFields(savedState.availableFields);
    setConfig(savedState.config);
    setAnalysisMode(savedState.analysisMode);
    return true;
  }
}

Five minutes is arbitrary. It was picked because that is roughly the duration of a single analyst session: pick a field, scale it, screenshot it, compare to a colleague's number, move on. Longer than that and the underlying source might have been replaced by an upload; shorter than that and the panel feels forgetful.

What shipped and what did not

What shipped: the panel, the field analysis, the height and color expressions, the quartile color scheme, the camera auto-tilt, the per-layer extrusion sibling, and a small donut chart in the panel that shows the field's min / mean / max distribution as a sanity check while the map shows the spatial picture. The donut is not the analysis — the prisms are. The donut is the reassurance that the height the analyst is looking at is the field they think it is.

What did not ship in the first cut: animated transitions when changing fields (the prisms snap to the new height; tweening them looks nicer but costs a frame budget the layer cannot afford on dense data), a categorical color scheme for non-numeric overlays (out of scope — the panel only handles numeric fields by design), and a per-feature label overlay showing the value at the top of each prism (legible at three prisms, illegible at a thousand, and the camera angle changes constantly).

The recent commit history on this surface — feat: multi layer analysis, feat: multi layer analysis colors, fix: preventing removing analysis layers — is mostly about letting the analyst keep two extrusion layers visible at once and not accidentally clobber one when the other is dismissed. That tells the real story: once the basic prism-on-polygon worked, the followups were all about composition, not the extrusion itself. That is the sign of a primitive that was the right shape.

Trade-off

Height-as-data accepts the cost of pitched views: occlusion of back-row features, harder distance comparison, and the analyst getting motion-sick if they pitch too aggressively. The mitigation is the per-feature opacity (0.8 by default) so back-row prisms remain visible through front-row ones, and the cap on max height so the city does not become a wall. There is no version of this where 3D is "free." It is worth doing only when the question is comparative magnitude and the dataset is sparse enough that occlusion does not dominate. Hex grids and TAZ polygons fit; a thousand small parcels in a downtown block do not.

Business impact

The choropleth-plus-sidebar version of this screen took analysts about ninety seconds to answer "which three districts stand out and by how much?". The extrusion version takes about ten, because the comparison happens on the geometry instead of through a hover-and-mentally-subtract loop. Multiply that by the number of times a day an analyst asks a magnitude question and you are buying back roughly a working hour per analyst per week. The cost was one config type, one util file, one panel component, and a sibling layer — measured in days, not weeks.

What to do next

If you have a viewer with a numeric overlay and an analyst who keeps switching between the map and a sidebar table, sit behind them for an afternoon and count how many times they hover-then-look-away. If the answer is more than ten, the bar chart belongs on the geometry. Read your map engine's docs for the equivalent of fill-extrusion — every modern vector engine has one — and write the smallest possible config object that lets a non-developer pick the field, scale it, and turn it on. Skip the second canvas. Skip the animated transitions in the first cut. Ship the prism.

Related Articles

Same Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox