Attribution Stacking for Multi-Source Basemaps
Generate the attribution block from the layer manifest at render time, deduplicating by rights holder rather than by dataset, and cap what is displayed with an expandable full list — a basemap drawing on twenty sources owes twenty credits, and a notice nobody can read satisfies nobody.
Attribution stacking is the problem that appears once a basemap stops being one dataset. Each source imposes its own credit, the credits accumulate, and the naive result is a paragraph of legal text under a map that has room for one line. The failure is not aesthetic: teams respond to the overflow by truncating the notice arbitrarily, and an arbitrary truncation drops obligations. This guide covers generating a notice that is both complete and displayable. It sits under license compatibility and derivative works within Geospatial Data Licensing & Compliance Fundamentals, and complements automated attribution mapping workflows, which covers extracting the credits in the first place.
Automated Python Implementation
The generator below takes a layer manifest and produces both forms of the notice. It groups by holder because that is the unit the obligation actually attaches to: an agency that supplied six layers is owed one credit, not six.
#!/usr/bin/env python3
"""Build a stacked attribution notice from a basemap layer manifest."""
import json
from collections import defaultdict
# Licences that impose no attribution obligation. Credits for these are
# courtesy, and are kept in a separate list so they can be dropped under
# space pressure without dropping an obligation.
NO_OBLIGATION = {"CC0-1.0", "PDDL-1.0", "public-domain"}
def load_manifest(path):
with open(path, encoding="utf-8") as fh:
return json.load(fh)["layers"]
def group_credits(layers):
"""Return (obliged, courtesy) as holder -> sorted set of licence ids."""
obliged = defaultdict(set)
courtesy = defaultdict(set)
for layer in layers:
holder = layer["rights_holder"].strip()
spdx = layer["license"].strip()
target = courtesy if spdx in NO_OBLIGATION else obliged
target[holder].add(spdx)
return obliged, courtesy
def format_credit(holder, licences):
"""One holder, however many licences they supplied under."""
return f"{holder} ({', '.join(sorted(licences))})"
def build_notice(layers, visible_limit=3):
obliged, courtesy = group_credits(layers)
# Deterministic order: most layers first, then alphabetically, so the
# notice is stable across builds and the largest contributors lead.
counts = defaultdict(int)
for layer in layers:
counts[layer["rights_holder"].strip()] += 1
holders = sorted(obliged, key=lambda h: (-counts[h], h))
full = [format_credit(h, obliged[h]) for h in holders]
full += [format_credit(h, courtesy[h]) for h in sorted(courtesy)]
visible = full[:visible_limit]
hidden = len(full) - len(visible)
summary = " · ".join(visible)
if hidden > 0:
summary += f" · and {hidden} more"
return {
"summary": summary,
"full": full,
"obliged_holders": len(obliged),
"courtesy_holders": len(courtesy),
}
if __name__ == "__main__":
import sys
notice = build_notice(load_manifest(sys.argv[1]))
print(notice["summary"])
print()
for line in notice["full"]:
print(" -", line)
Three decisions in that code carry the whole design.
Obliged and courtesy credits are separated. A CC0 source may be credited as good practice, but it is not owed one. Keeping the two lists distinct means that when space is short, the thing dropped from view is the credit that was never required — and that the count of genuine obligations is always known.
Ordering is deterministic and meaningful. Sorting by contribution count then alphabetically gives a stable notice across builds and puts the largest contributors where a reader will see them. Random or insertion order produces a diff on every rebuild and a notice whose visible portion is arbitrary.
Nothing is discarded. full always contains every credit. The visible summary is a view over it, not a replacement, and the count of hidden entries is displayed so that a reader knows there is more.
Validation and Pipeline Integration
The assertion that matters is that no obligation disappeared between the manifest and the notice. It is a set comparison and it belongs in CI.
def test_every_obliged_holder_appears_in_full_notice():
layers = [
{"rights_holder": "City", "license": "ODbL-1.0"},
{"rights_holder": "City", "license": "CC-BY-4.0"},
{"rights_holder": "Agency", "license": "CC-BY-4.0"},
{"rights_holder": "Registry", "license": "CC0-1.0"},
]
notice = build_notice(layers, visible_limit=1)
joined = " ".join(notice["full"])
assert "City" in joined and "Agency" in joined
assert notice["obliged_holders"] == 2
assert notice["courtesy_holders"] == 1
def test_summary_reports_hidden_count():
layers = [
{"rights_holder": f"Holder {i}", "license": "CC-BY-4.0"} for i in range(6)
]
notice = build_notice(layers, visible_limit=2)
assert "and 4 more" in notice["summary"]
def test_notice_is_stable_across_runs():
layers = [
{"rights_holder": "B", "license": "CC-BY-4.0"},
{"rights_holder": "A", "license": "CC-BY-4.0"},
]
assert build_notice(layers)["full"] == build_notice(layers)["full"]
Generate the notice at build time and commit it alongside the basemap style, rather than assembling it in the browser. A notice built in the client depends on JavaScript running, and an attribution that disappears when a script fails is an attribution that was not given. Where the map is interactive, render the full list into the page and let the control expand it — the obligation is discharged by the markup, not by the interaction.
Long-Term Compliance Best Practices
- Normalise holder names before grouping. “City of Example” and “City of Example Council” produce two credits for one organisation and make the notice longer than it needs to be. A small alias table, reviewed occasionally, is enough.
- Regenerate the notice whenever the layer manifest changes, and only then. A notice that is edited by hand after generation will be overwritten silently at the next build, which is how a manually added credit disappears.
- Keep the machine-readable form. Emit the JSON alongside the rendered text so that downstream consumers — a data portal, a printed map export, a PDF report — build their own presentation from the same facts rather than parsing your string.
- Treat an unknown licence as obliged. A layer whose licence could not be resolved should appear in the notice, not be omitted for want of a rule. The visible reminder is what prompts somebody to resolve it.
- Cap the visible list by width, not by count. Three long agency names overflow where six short ones do not. Measuring the rendered width and filling until it is exhausted produces a notice that fits on every device rather than on the one it was designed on.
Related
- Automated Attribution Mapping Workflows — extracting the credits from source metadata before they can be stacked
- License Compatibility & Derivative Works — deciding whether the combination that produced the basemap was permissible at all
- Combining ODbL and CC-BY Layers in One Product — the two-source case, with the licence written into the data
- Creative Commons Licensing for GIS Datasets — which CC identifiers impose an attribution obligation and which do not