Short answer

An interactive cross section on the web is two synchronised views sharing one section identifier: a map view that shows the line of section as a clickable trace, and a section panel that draws the subsurface geology in section-space coordinates (distance along the trace on the X axis, elevation on the Y axis). Hovering the map moves a cursor in the section, and hovering the section moves a marker on the map. Both views read vector data with attributes, so a user can click a unit at depth and read what it is — not just look at a picture.

The whole design hinges on one conversion you implement once and reuse everywhere: map position ↔ chainage along the trace. Get that mapping right and the rest is rendering.

Architecture: two views, one section ID

Keep responsibilities separated:

  • Map layer (MapLibre GL JS or deck.gl). Loads the section_lines GeoJSON in the project's display CRS — for web that is Web Mercator (EPSG:3857) under the hood, with source data in WGS84 (EPSG:4326). Each trace carries section_id. A PathLayer (deck.gl) or a line layer (MapLibre) makes it interactive; queryRenderedFeatures returns the section under the cursor.
  • Section panel (SVG or Canvas). A separate DOM element, not part of the map canvas. It draws the section's GeoJSON in section space using a linear scale: xScale: chainage_m → pixels, yScale: elevation_m → pixels (inverted, because screen Y grows downward). D3 scales (d3.scaleLinear) handle this cleanly.
  • Shared state. A small store (even a plain object with callbacks) holds the active section_id and the current chainage_m. Both views subscribe to it.

This separation is why the section stays queryable: it is its own GeoJSON document with map_unit, fault_type, and confidence properties, not a flattened image stitched onto the basemap.

The core sync: chainage to lng/lat and back

The section's horizontal axis is metres along the trace. The map needs a lng/lat. With turf.js this is a two-liner:

import along from '@turf/along';
import length from '@turf/length';

const totalKm = length(traceFeature, { units: 'kilometers' });

// section hover → map marker
function chainageToLngLat(chainage_m) {
  return along(traceFeature, chainage_m / 1000, { units: 'kilometers' })
    .geometry.coordinates;          // [lng, lat]
}

For the reverse direction (map hover → section cursor), project the hovered point onto the line. turf's nearestPointOnLine returns a location property that is the distance along the line in your chosen units:

import nearestPointOnLine from '@turf/nearest-point-on-line';

function lngLatToChainage(lngLat) {
  const snapped = nearestPointOnLine(traceFeature, lngLat, { units: 'kilometers' });
  return snapped.properties.location * 1000;   // metres along trace
}

Wire mousemove on the map to lngLatToChainage → update store → section redraws its cursor; wire mousemove on the section panel to chainageToLngLat → move a MapLibre Marker. For performance on long, densely vertexed traces, precompute a cumulative-distance lookup table of vertices once and binary-search it instead of calling turf on every pointer event.

Rendering the section panel

Convert the section GeoJSON to screen coordinates with two scales and draw:

const x = d3.scaleLinear().domain([0, sectionLengthM]).range([margin, width - margin]);
const y = d3.scaleLinear().domain([minElev, maxElev]).range([height - margin, margin]);
  • Topographic profile as a path along the top.
  • Unit polygons filled from the shared legend colour for map_unit.
  • Faults as styled lines keyed by fault_type (e.g. dashed for inferred).
  • Drillholes as vertical sticks at their chainage, intercepts as ticks.

Apply vertical exaggeration in the scale, never in the data: set the Y range so 1 vertical metre maps to more pixels than 1 horizontal metre, and show the factor in the panel header (e.g. "VE 5×"). Always display a real horizontal scale bar and a vertical axis so users are not misled by the stretch.

SVG vs Canvas vs WebGL

  • SVG is the default for geological sections: a few hundred polygons and lines, crisp labels, and DOM events (mouseenter per feature) for free. Use it unless you measure a problem.
  • Canvas when feature counts climb into the thousands or you want smooth pan/zoom; you lose per-element DOM events and do hit-testing yourself.
  • WebGL (deck.gl in a second view, or regl) only for very heavy sections — dense fence diagrams, thousands of drillhole sticks.

Worked flow: from PostGIS to the browser

  1. Store traces in map CRS and section geometry in section space (see Cross Section Data in GIS Workflows).
  2. Export traces as WGS84 GeoJSON for the map: ogr2ogr -f GeoJSON -t_srs EPSG:4326 traces.geojson "PG:dbname=geo" section_lines.
  3. Export each section's geology as GeoJSON in section-space coordinates (X = chainage, Y = elevation), carrying map_unit, confidence, fault_type.
  4. Ship a small legend JSON mapping map_unit → colour and label, shared by map and section so symbology never drifts.
  5. On load, fetch traces; on trace click, lazy-load that section's GeoJSON and render the panel.

Common pitfalls and why they happen

  • Sending a section image instead of features. A PNG cannot be hovered, filtered, or queried; it happens because exporting a layout is easy. Serve GeoJSON so depth queries work.
  • Vertical exaggeration baked into stored coordinates. Then the apparent dip is wrong everywhere and cannot be undone. Keep VE in the render scale and label it.
  • No scale bar on an exaggerated panel. Users read false dips and distances. Always show horizontal and vertical scales plus the VE factor.
  • Recomputing chainage with turf on every mousemove. On long traces this stutters. Precompute a cumulative-distance vertex table and interpolate.
  • CRS mismatch between trace and basemap. Source data must be WGS84 for the web; reproject with ogr2ogr -t_srs EPSG:4326 rather than relabelling.

QA and validation

Check that hovering the start of the section (chainage 0) lands the map marker exactly on the trace's start vertex; that the section's left edge corresponds to the labelled start point (A, not A′); that legend colours in the panel match the map; that the displayed VE factor matches the scales' actual ratio; and that drillhole sticks land at plausible chainages (none beyond the trace length).

Bathyl perspective

We build cross-section interfaces as linked, queryable views rather than embedded images, so a reviewer can move along the trace and read units, faults, and confidence at depth. The single reusable primitive — chainage to lng/lat and back — keeps the map and the section honest with each other.

Related reading

Sources