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_linesGeoJSON 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 carriessection_id. APathLayer(deck.gl) or alinelayer (MapLibre) makes it interactive;queryRenderedFeaturesreturns 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_idand the currentchainage_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 (
mouseenterper 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
- Store traces in map CRS and section geometry in section space (see Cross Section Data in GIS Workflows).
- Export traces as WGS84 GeoJSON for the map:
ogr2ogr -f GeoJSON -t_srs EPSG:4326 traces.geojson "PG:dbname=geo" section_lines. - Export each section's geology as GeoJSON in section-space coordinates (X = chainage, Y = elevation), carrying
map_unit,confidence,fault_type. - Ship a small legend JSON mapping
map_unit→ colour and label, shared by map and section so symbology never drifts. - 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:4326rather 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
- Cross Section Data in GIS Workflows
- Metadata Panels for Geological Features
- Preparing GIS Data for Web Visualization
- Mapping systems