Skip to content

A palette that survives six overlapping layers: the colour decision we shipped twice

7/24/2025UI/UX DesignGeospatial Urban Analytics Platform12 min read

A palette that survives six overlapping layers: the colour decision we shipped twice

Two layers is colour theory. Six layers is a constraint solver. The story of a palette that had to be designed, scrapped, and designed again.

The brief in one paragraph

A geospatial analytics app — Next.js 15 App Router, MapLibre GL on the canvas — needed to render several analysis layers on top of base layers at the same time. The product team had been asking for it for months. Two analysis types looked fine. Three started to feel like noise. By six, the map was an unreadable bruise. The fix was not "pick prettier colours". The fix was to treat the palette as a constraint and rebuild the editor that produced it. The commit history of components/viewer/ shows the iteration plainly — feat: multi layer analysis, then feat: multi layer analysis colors, then feat: layer scrolls, then fix: preventing removing analysis layers. Each one is the previous one's bug report.

The constraint that shaped everything

The viewer renders polygon fills. MapLibre paints fills as a single fill-color per layer, with fill-opacity blending the layer on top of whatever was painted before. Stack six of those, each at 60 percent opacity, and the result is colour arithmetic — every pixel is a weighted sum of however many polygons overlap it. The analyst is supposed to read that pixel and tell you which layers contributed.

The constraint, then, was not "make the colours nice". The constraint was: pick a palette such that any two overlapping polygons remain visually separable, and any single polygon remains identifiable against the basemap underneath. The repo encodes that constraint in two places — a ramp palette for continuous data, and a categorical palette for distinct layers — and they are not the same thing.

Here is the ramp palette, lifted from components/viewer/ui/LeftMenu/partials/LayersPanel/partials/EditLayer/controllers/LayerPaintController.ts:

const RAMP_COLORS = [
  '#b3d3e8', '#79a6bf', '#5a8bb0', '#4693c8',
  '#3a7fc0', '#255f97', '#1a4c7d', '#143b6d'
]

Eight stops, all in the same blue family, brightness descending. That works for one layer — a density choropleth, say — because the eye reads it as a single quantity. Stack it next to a second ramp in the same family and the two layers visually merge. The categorical palette in components/viewer/essentials/LeftInsiderMenu/partials/LayersPanel/partials/EditLayer/index.tsx is the answer to that:

const categoricalPalettes: Record<string, string[]> = {
  'Professional': ['#1f77b4','#ff7f0e','#2ca02c','#d62728','#9467bd','#8c564b','#e377c2','#7f7f7f','#bcbd22','#17becf'],
  'RedOrange':    ['#fff5f0','#fee0d2','#fcbba1','#fc9272','#fb6a4a','#ef3b2c','#cb181d','#a50f15','#67000d'],
  'BluePurple':   ['#f7fbff','#deebf7','#c6dbef','#9ecae1','#6baed6','#4292c6','#2171b5','#08519c','#08306b']
}

Professional is Vega's tableau10 — the d3 default, but smarter than it looks. It was picked because every adjacent pair has a measurable hue distance, not because somebody liked the swatches. The other two are sequential — useful for one layer of categorical-but-ordered data, useless for six unrelated layers. That distinction is the whole post.

The architecture, against the actual repo layout

The viewer subsystem lives under components/viewer/. Inside, there are two parallel LayersPanel trees — one under ui/LeftMenu/partials/LayersPanel/ and another under essentials/LeftInsiderMenu/partials/LayersPanel/. That duplication is not accidental. The first version of the panel was good enough for two or three layers. When the multi-layer feature landed in commit ba6e0b8 feat: multi layer analysis, the limits of the first panel showed up immediately, and the team built a second one alongside it rather than refactor in place. The colour code lives in both — the older one in helpers/colors.ts, the newer one inline in EditLayer/index.tsx plus EditLayer/partials/InteractiveRampEditor.tsx. We will use the newer code for the rest of this post because that is where the iteration is happening.

The categorical palette is applied in FillStyleEditor by walking the unique values of a feature property and emitting a MapLibre match expression. The relevant section:

const buildUniqueFromMap = async () => {
  if (!uniqueProp || !map) return
  const opts = sourceLayer ? { sourceLayer } : undefined
  const feats = map.querySourceFeatures(sourceId as any, opts) as any[]
  const seen: Record<string, number> = {}
  for (const f of feats) {
    const v = f?.properties?.[uniqueProp]
    const key = String(v ?? '<Null>')
    seen[key] = (seen[key] ?? 0) + 1
  }
  const values = Object.keys(seen).sort((a,b)=>seen[b]-seen[a]).slice(0, 100)
  const colors = categoricalPalettes[paletteKey]
  const parts: any[] = []
  values.forEach((val, i) => { parts.push(val, colors[i % colors.length]) })
  const expr: any = ['match', ['to-string', ['get', uniqueProp]], ...parts, '#cccccc']
  updatePaint('fill-color', expr)
}

Two things to notice. First, the palette wraps with colors[i % colors.length]. If you have eleven unique values and a ten-colour palette, the eleventh value collides with the first. There is no warning. The bug was reported once, the response was "we cap categorical layers at ten", and the cap is enforced via the .slice(0, 100) on the features (not the values) — which means it isn't really enforced at all. We'll come back to this in trade-offs. Second, '#cccccc' is the fallback colour for any value the match expression doesn't recognise. That grey was deliberate: it has to read as "I don't know" against every other palette colour, and the categorical palettes were chosen so that grey would always read as off.

The continuous case — gradient, not ramp

When the data is continuous (population density, accessibility score) the editor builds a gradient between two endpoints. The interpolation happens in HSL, not RGB, because RGB interpolation between two saturated hues passes through muddy intermediate colours. The HSL function is hand-rolled in EditLayer/index.tsx — the team did not pull in a colour library for this even though culori@^4.0.2 is already in package.json. The reason is in the code comments — they needed to control the wrap-around at the hue boundary:

function generateGradient(startColor: string, endColor: string, steps: number): string[] {
  const start = hexToHsl(startColor)
  const end = hexToHsl(endColor)
  const colors: string[] = []

  if (steps <= 1) return [startColor]

  for (let i = 0; i < steps; i++) {
    const factor = i / (steps - 1)
    let h = start.h + (end.h - start.h) * factor
    if (Math.abs(end.h - start.h) > 180) {
      if (end.h > start.h) {
        h = start.h + (end.h - start.h - 360) * factor
      } else {
        h = start.h + (end.h - start.h + 360) * factor
      }
    }
    h = ((h % 360) + 360) % 360
    const s = start.s + (end.s - start.s) * factor
    const l = start.l + (end.l - start.l) * factor
    colors.push(hslToHex(h, s, l))
  }
  return colors
}

The block from if (Math.abs(end.h - start.h) > 180) is the whole point of that function. Without it, a gradient from red (hue 0) to purple (hue 280) walks the long way around the colour wheel and passes through green. The reader sees a population density layer that goes red, orange, yellow, green, blue, purple, and asks a perfectly reasonable question — why does the green spike in the middle of my data? The first version of this code did not have that branch. It was added during the second pass.

The endpoints for each preset palette come out of a small lookup, and this is where the categorical/continuous split shows up in the API:

const getGradientColorsFromPalette = (paletteName: string): { start: string; end: string } => {
  switch (paletteName) {
    case 'RedOrange':
      return { start: '#ff6b35', end: '#dc2626' }
    case 'BluePurple':
      return { start: '#3b82f6', end: '#7c3aed' }
    case 'Professional':
      return { start: '#1f77b4', end: '#d62728' }
    default:
      return { start: '#ff6b35', end: '#dc2626' }
  }
}

Professional doubles up: as a categorical it gives ten distinct hues, as a gradient it returns blue-to-red. That single fall-back keeps the editor sane when a user toggles a layer from categorical to continuous mid-session — the colours shift but the palette identity does not.

The hardest sub-problem — opacity stacking

The palette problem is half the work. The other half is opacity. MapLibre paints fills with fill-opacity in [0,1], and the default in LayerPaintController.makeSmartRampPaint is 0.8:

case 'fill':
  return { 'fill-color': expr, 'fill-opacity': 0.8 }

Eighty percent is fine for one layer. For six stacked layers it is wrong by a lot. The blended pixel under six 80-percent fills is dominated by the top layer; the bottom layers are visible only at the edges where the upper polygons don't cover. That is exactly the case where the analyst needs to see all six.

The shipped fix is twofold. The default opacity stays at 0.8 — the single-layer case is the most common, and dropping the default would degrade it. Each layer can be tuned independently in the editor, via the OpacityControl.tsx slider, and the FillStyleEditor exposes it as a 0..100 percent input that writes back to fill-opacity:

<input
  type="range"
  min={0}
  max={100}
  value={Math.round((paint['fill-opacity'] ?? 1) * 100)}
  onChange={e =>
    updatePaint('fill-opacity', parseInt(e.target.value) / 100)
  }
/>

The product-side rule, undocumented but enforced in support tickets, is: if you have more than three analysis layers active, drop each one to around 40 percent. The team considered automating that — a hook that lowers opacity as layer count goes up — and decided against it. Automated opacity is fine until the analyst exports a screenshot and discovers that the colours they were reading on screen are not the colours in the PNG. The decision to leave opacity manual is the kind of trade-off that does not feel important until the third client meeting where someone asks "why is the report not matching the map".

What shipped, and what got shipped again

Multi-layer analysis landed in commit ba6e0b8. The colour work landed three commits later in 14e48c9 feat: multi layer analysis colors. Between those two, the work item changed shape entirely. The first version used the existing RAMP_COLORS from LayerPaintController.ts for every new analysis layer, which is why three layers all looked like the same blue choropleth. The second version added the categorical palettes inline in EditLayer/index.tsx, separated the gradient endpoints from the categorical lists, and added the HSL hue-wrap branch in generateGradient. Then f8ecb55 feat: layer scrolls added the scrolling panel that became necessary because once the colours worked the analysts started stacking ten layers, and c263478 fix: preventing removing analysis layers patched the bug where a click on the colour swatch accidentally triggered the delete handler. That sequence — feature, then colours, then the UI affordances the colours enabled, then the bug the new interactions surfaced — is exactly how this kind of work goes when you ship it twice.

A fair criticism is that the team ended up with two LayersPanel trees in the repo. The older one under ui/LeftMenu/ still works and is still wired in some routes. The newer one under essentials/LeftInsiderMenu/ is where the colour iteration happened. Consolidating the two is on the backlog, not on the critical path. That is a real cost, and naming it is part of the post-mortem.

Trade-off

The choice to keep the gradient maths hand-rolled — instead of using the culori@^4.0.2 library that is already a dependency — is a trade-off, not an oversight. culori would give the team OKLCH interpolation, which is perceptually uniform and would remove the need for the HSL hue-wrap branch entirely. The cost is a runtime dependency on a colour library in a hot UI path, plus the team-readability cost of moving colour logic from "fifteen lines of arithmetic visible in the file" to "an import that someone has to chase". The team chose readability today over correctness in a hypothetical future when the gradients get more sophisticated. If the gradient logic gets extended again — a third endpoint, a non-linear ramp — the trade-off flips. Until then, it stands.

A second trade-off is the manual opacity rule. Auto-opacity would help new users; it would also produce a class of bug reports — "the screenshot does not match what I see" — that the team did not want to take on. That decision is reversible the day someone builds a "match my screen" export.

Business impact

The product can now sell multi-layer analysis as a feature, not a footnote. The team specifically uses six overlapping layers in the demo because the colour palette was designed around that case, and a demo that holds up under six layers reads as "this is a serious tool" in a way that two-layer demos do not. The cost of the second iteration — roughly a week of engineer time across the commit range from ba6e0b8 to c263478 — was paid back in the first month of demos. The cost of the duplicate LayersPanel tree is still being paid in onboarding time for new contributors, and that is the next refactor.

What to do next

If you are about to ship a multi-layer visualisation feature, do not start with the colours. Start with the constraint — how many overlapping layers does the user need to read at once? Then design the palette as a function of that number, not as a list of swatches that look nice in isolation. The category-vs-continuous split is the first decision; the opacity rule is the second; the hue interpolation algorithm is the third. Each of those is a place where the wrong default produces a feature the product team will reject without being able to articulate why.

If you have already shipped one, run the demo with six layers on and ask whether the analyst can still answer the question. If the answer is "kind of", you have a palette problem, not a perception problem. The palette is fixable. Open the editor, look at the file that lists the hex codes, and check whether the array was chosen for distance or chosen for taste. Then ship it twice.

Related Articles

Same Category

Comments (0)

Newsletter

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