diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d83334ceb..0d77efa8b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -160,12 +160,17 @@ boilerplate in `scripts/taginfo_template.json`. 1. Please prettify all files prior to submission. Run `npm run code_format` to format code files with [prettier][90] and SVG files with [SVGO][svgo]. -2. If you are introducing a novel approach to depicting a layer or feature +2. If you are introducing a new kind of feature to the style, add a section to + `src/js/legend_config.js` or a legend entry in the corresponding file in + `src/layer/` that tells the Legend control how to find and render a + representative feature. Also try out the Samples button to catch any visual + conflicts. +3. If you are introducing a novel approach to depicting a layer or feature property from the OpenMapTiles schema, document how the corresponding OpenStreetMap key or tag is used in `scripts/taginfo_template.json`. -3. If any shield background icons are introduced, add lines to `src/shieldtest.js` +4. If any shield background icons are introduced, add lines to `src/shieldtest.js` to demonstrate overlaid text on each of them. -4. If you are introducing new JavaScript code that can run independently of a +5. If you are introducing new JavaScript code that can run independently of a browser environment, add automated unit tests for it to `test/spec/`, then run `npm test` to ensure that they pass. This project structures unit tests using [Chai](https://www.chaijs.com/guide/styles/) for assertions. diff --git a/README.md b/README.md index 80bc0c53e..75addefd4 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ The Americana style is the first digital map to achieve concurrent, state-specif ## How to use -You can install the OpenStreetMap Americana package and [deploy it anywhere](CONTRIBUTING.md#Production%20builds) as a static webpage. For your convenience, we’ve deployed it [on GitHub Pages](https://zelonewolf.github.io/openstreetmap-americana/). To explore how the style depicts various features, click the Samples button. +You can install the OpenStreetMap Americana package and [deploy it anywhere](CONTRIBUTING.md#Production%20builds) as a static webpage. For your convenience, we’ve deployed it [on GitHub Pages](https://zelonewolf.github.io/openstreetmap-americana/). Click the Legend button to learn the meaning of each symbol, line, and color based on the features currently visible on the map. The style tries to label places in [your browser’s preferred language](https://www.w3.org/International/questions/qa-lang-priorities). To change this preference, consult your browser’s documentation: [Chrome](https://support.google.com/chrome/answer/173424), [Firefox](https://support.mozilla.org/en-US/kb/use-firefox-another-language), [Safari for macOS](https://support.apple.com/guide/mac-help/change-the-system-language-mh26684/mac), [Safari for iOS](https://support.apple.com/en-us/HT204031). You can also override this preference by adding `&language=` to the URL, followed by a comma-separated list of [IETF language tags](https://www.w3.org/International/articles/language-tags/). For example, here’s a map labeled [in Portuguese, falling back to Spanish](https://zelonewolf.github.io/openstreetmap-americana/#language=pt,es). If we don’t have the name of a place in any of your preferred languages, the style shows the name in the local language as a last resort. @@ -55,6 +55,8 @@ The OpenStreetMap Americana style is built upon the [OpenMapTiles schema](https: - Translated name labels from [Wikidata](https://www.wikidata.org/wiki/Wikidata:Main_Page) for places, POIs, airports, roads, bodies of water, parks, and mountain peaks. - Low-zoom ocean/water, boundary, and urbanized area data from [Natural Earth](https://www.naturalearthdata.com/). +The legend’s “Route markers” section is labeled using labels of Wikidata items that are tagged with the [corresponding OSM tag](https://www.wikidata.org/wiki/Property:P1282). + ## Coverage Americana is compatible with vector tiles covering the entire world. diff --git a/src/americana.js b/src/americana.js index e8bb49180..797eb53c2 100644 --- a/src/americana.js +++ b/src/americana.js @@ -4,7 +4,6 @@ import config from "./config.js"; import * as Label from "./constants/label.js"; -import * as Util from "./js/util.js"; import * as Shield from "./js/shield.js"; import * as ShieldDef from "./js/shield_defs.js"; @@ -31,6 +30,8 @@ import * as maplibregl from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import * as search from "./search.js"; +import LegendControl from "./js/legend_control.js"; +import * as LegendConfig from "./js/legend_config.js"; import SampleControl from "openmapsamples-maplibre/OpenMapSamplesControl.js"; import { default as OpenMapTilesSamples } from "openmapsamples/samples/OpenMapTiles/index.js"; @@ -205,28 +206,7 @@ function buildLayers() { lyrOneway.bridgeLink, ]; - //Render bridge without layer on the lowest bridge layer - bridgeLayers.forEach((layer) => - layers.push( - Util.filteredClone(layer, ["!", ["has", "layer"]], "_layer_bottom") - ) - ); - - //One layer at a time to handle stacked bridges - for (let i = 1; i <= 4; i++) { - bridgeLayers.forEach((layer) => layers.push(Util.restrictLayer(layer, i))); - } - - //If layer is more than 5, just give up and render on a single layer. - bridgeLayers.forEach((layer) => - layers.push( - Util.filteredClone( - layer, - [">=", ["coalesce", ["get", "layer"], 0], 5], - "_layer_top" - ) - ) - ); + layers.push(...lyrRail.getLayerSeparatedBridgeLayers(bridgeLayers)); layers.push( //The labels at the end of the list draw on top of the layers at the beginning. @@ -326,14 +306,17 @@ map.on("styleimagemissing", function (e) { Shield.missingIconHandler(map, e); }); -export function hotReloadMap() { +function hotReloadMap() { map.setStyle(buildStyle()); } export function updateLanguageLabel() { languageLabel.displayLocales(Label.getLocales()); + legendControl.onLanguageChange(); } +let legendControl = new LegendControl(); +legendControl.sections = LegendConfig.sections; window.addEventListener("languagechange", (event) => { console.log(`Changed to ${navigator.languages}`); hotReloadMap(); @@ -372,6 +355,8 @@ if (config.ATTRIBUTION_LOGO != undefined) { map.addControl(new search.PhotonSearchControl(), "top-left"); map.addControl(new maplibregl.NavigationControl(), "top-left"); +map.addControl(legendControl, "bottom-left"); + // Add our sample data. let sampleControl = new SampleControl({ permalinks: true }); OpenMapTilesSamples.forEach((sample, i) => { diff --git a/src/index.html b/src/index.html index 5a8c34873..4c8d2c26c 100644 --- a/src/index.html +++ b/src/index.html @@ -30,6 +30,58 @@ .openmapsamples-control-container { margin-left: 70px !important; } + + #legend-container { + max-height: 60vh; + overflow-y: scroll; + } + .legend-section > summary { + background-color: #eee; + } + .legend-row, + .legend-row img { + vertical-align: middle; + } + .legend-row a { + color: black; + } + .legend-row > .label, + .legend-row > .icon { + width: 0; + text-align: center; + } + .legend-row > .icon { + white-space: nowrap; + } + .legend-row .shield + .shield { + margin-left: 0.2em; + } + .legend-row > .swatch, + .legend-row > .line { + vertical-align: middle; + width: 4em; + } + .legend-row > .swatch { + border-style: solid; + } + .legend-row > .line > svg { + vertical-align: middle; + width: 100%; + } + .legend-row .language { + font-style: italic; + font-size: 80%; + line-height: 1; + vertical-align: super; + } + .legend-source { + font-size: 80%; + text-align: right; + } + .legend-source, + .legend-source a { + color: #aaa; + } @@ -53,5 +105,48 @@ data-recalc-dims="1" style="position: absolute; top: 0; right: 0; border: 0; z-index: 100" /> + + + + + + diff --git a/src/js/legend_config.js b/src/js/legend_config.js new file mode 100644 index 000000000..2b32a7a21 --- /dev/null +++ b/src/js/legend_config.js @@ -0,0 +1,58 @@ +"use strict"; + +import * as PlaceLayers from "../layer/place.js"; +import * as LanduseLayers from "../layer/landuse.js"; +import * as BoundaryLayers from "../layer/boundary.js"; +import * as RoadLayers from "../layer/road.js"; +import * as ConstructionLayers from "../layer/construction.js"; +import * as HighwayExitLayers from "../layer/highway_exit.js"; +import * as RailLayers from "../layer/rail.js"; +import * as AerowayLayers from "../layer/aeroway.js"; +import * as ParkLayers from "../layer/park.js"; +import * as BuildingLayers from "../layer/building.js"; +import * as WaterLayers from "../layer/water.js"; +import * as FerryLayers from "../layer/ferry.js"; + +export const sections = [ + { + name: "Populated places", + entries: PlaceLayers.legendEntries, + }, + { + name: "Borders", + entries: BoundaryLayers.legendEntries, + }, + { + name: "Roads", + entries: [ + ...RoadLayers.legendEntries, + ...ConstructionLayers.legendEntries, + ...HighwayExitLayers.legendEntries, + ], + }, + { + id: "shields", + name: "Route markers", + source: "Wikidata", + }, + { + name: "Railroads", + entries: RailLayers.legendEntries, + }, + { + name: "Aviation", + entries: AerowayLayers.legendEntries, + }, + { + name: "Structures", + entries: BuildingLayers.legendEntries, + }, + { + name: "Land use", + entries: [...LanduseLayers.legendEntries, ...ParkLayers.legendEntries], + }, + { + name: "Water", + entries: [...WaterLayers.legendEntries, ...FerryLayers.legendEntries], + }, +]; diff --git a/src/js/legend_control.js b/src/js/legend_control.js new file mode 100644 index 000000000..dddf60b7a --- /dev/null +++ b/src/js/legend_control.js @@ -0,0 +1,743 @@ +"use strict"; + +import * as ShieldDraw from "./shield_canvas_draw.js"; +import * as Label from "../constants/label.js"; +import * as ShieldDef from "./shield_defs.js"; + +import * as LegendConfig from "./legend_config.js"; +import * as HighwayShieldLayers from "../layer/highway_shield.js"; + +import * as maplibregl from "maplibre-gl"; + +const maxPopupWidth = 30; /* em */ + +export default class LegendControl { + onAdd(map) { + this._map = map; + + this._container = document.createElement("div"); + this._container.className = "maplibregl-ctrl"; + this._container.style.clear = "none"; + + let controlGroup = document.createElement("div"); + controlGroup.className = "maplibregl-ctrl-group"; + this._container.appendChild(controlGroup); + + let button = document.createElement("button"); + button.textContent = "Legend"; + button.style.width = "5em"; + controlGroup.appendChild(button); + + this._popup = new maplibregl.Popup({ + closeOnMove: true, + anchor: "bottom-left", + maxWidth: `${maxPopupWidth}em`, + offset: [0, -4], + }); + button.addEventListener("click", () => { + if (this._popup.isOpen()) { + this.close(); + return; + } + + // A popup is normally anchored on a geographic location, but we just want + // it to point to the button. + let buttonRect = button.getClientRects()[0]; + let anchor = [buttonRect.x, buttonRect.y]; + this.open(anchor); + }); + + return this._container; + } + + onRemove() { + this._container.parentNode.removeChild(this._container); + this._map = undefined; + } + + /** + * Call this method whenever the page's language changes. + */ + onLanguageChange() { + this.close(); + this.purgeNetworkMetadata(); + } + + /** + * Opens the legend popup, positioning it to point at the given anchor point. + * + * @param anchor A screen coordinate for the popup to point to. + */ + open(anchor) { + this.close(); + + let contents = this.getContents(); + let rows = contents.querySelectorAll(".legend-row"); + this._popup.setDOMContent(contents); + + let anchorCoordinate = this._map.unproject(anchor); + this._popup.setLngLat(anchorCoordinate).addTo(this._map); + + this.prettifyNetworkLabels(rows); + + document.getElementById("legend-container").scrollTop = 0; + } + + /** + * Closes the legend popup. + */ + close() { + this._popup.remove(); + } + + /** + * Returns contents of the popup appropriate to the current viewport. + * + * @returns A DOM element representing the full contents of the popup. + */ + getContents() { + let template = document.getElementById("legend").content.cloneNode(true); + + let shieldSection = this.sections.find((s) => s.id === "shields"); + shieldSection.rows = this.getShieldRows(); + shieldSection.sourceURL = `https://query.wikidata.org/embed.html#${encodeURIComponent( + this.getNetworkMetadataQuery() + )}`; + + for (let data of this.sections) { + let section = this.getSection(data); + if (!section) continue; + + let container = template.getElementById("legend-container"); + container.appendChild(section); + + if (data.source) { + let sourceCell = template.querySelector(".legend-source"); + sourceCell.textContent = "Source: "; + + let sourceLink = document.createElement("a"); + sourceLink.href = data.sourceURL; + sourceLink.textContent = data.source; + sourceCell.append(sourceLink); + } + } + + return template; + } + + /** + * Returns the section representing the given data. + */ + getSection(data) { + let template = document + .getElementById("legend-section") + .content.cloneNode(true); + template.querySelector("summary").textContent = data.name; + + let rows = data.rows; + if (!rows && data.entries) { + let entries = data.entries + .map((e) => this.getMatchedEntry(e)) + .filter((m) => m); + rows = entries.map((e) => this.getRowForEntry(e)).filter((r) => r); + } + if (!rows.length) return; + + template.querySelector("tbody").replaceChildren(...rows); + if (!data.source) { + template.querySelector("tfoot").remove(); + } + return template.querySelector(".legend-section"); + } + + /** + * Returns a copy of the given entry populated with a representative visible + * feature. + */ + getMatchedEntry(entry) { + // Query the map for rendered (including transparent) features in the + // current viewport that meet the entry's criteria. + let features = this._map.queryRenderedFeatures({ + layers: entry.layers, + filter: entry.filter, + }); + let mainFeature = features[0]; + if (!mainFeature) return; + + // Copy the entry, adding the first match, which is from the topmost layer. + let matchedEntry = { feature: mainFeature }; + Object.assign(matchedEntry, entry); + + if ( + mainFeature.layer.type === "fill" || + mainFeature.layer.type === "fill-extrusion" + ) { + // Pair a fill (extrusion) layer's polygon with some polygon rendered as + // an outline. + matchedEntry.fill = mainFeature; + matchedEntry.stroke = features.find( + (f) => f.id === mainFeature.id && f.layer.type === "line" + ); + } else if (mainFeature.layer.type === "line") { + // Pair a line layer's linestring with some polygon rendered as a fill. + matchedEntry.fill = features.find( + (f) => + f.id === mainFeature.id && + (f.layer.type === "fill" || f.layer.type === "fill-extrusion") + ); + if (matchedEntry.fill) { + matchedEntry.stroke = mainFeature; + } else { + // Collect the other linestrings needed to render the entry (casing + // etc.). + matchedEntry.lines = []; + let layers = new Set(); + for (let feature of features) { + // Ignore other features that happen to match the criteria so that the + // entry depicts only a single feature. + if (feature.id !== mainFeature.id || feature.layer.type !== "line") + continue; + // If we've already seen the layer, then this feature represents + // another slice of the feature in another tile. + if (layers.has(feature.layer.id)) continue; + layers.add(feature.layer.id); + // Populate the array of lines in reverse order, because SVG renders + // elements according to the painter's algorithm. + matchedEntry.lines.unshift(feature); + } + } + } + + return matchedEntry; + } + + /** + * Returns a table row illustrating the given entry. + */ + getRowForEntry(entry) { + // Choose an HTML template that will display as much information about the + // entry as possible. + let templateID = "legend-row-symbol"; + if (entry.lines) { + templateID = "legend-row-line"; + } else if (entry.fill) { + templateID = "legend-row-swatch"; + } + let template = document.getElementById(templateID).content.cloneNode(true); + let row = template.querySelector("tr"); + + // Populate the template's swatch etc. with an illustration of the matching + // feature. + if (entry.fill) { + let swatchCell = row.querySelector(".swatch"); + Object.assign( + swatchCell.style, + this.getSwatchStyle(entry.fill, entry.stroke) + ); + } else if (entry.lines) { + let lineCell = row.querySelector(".line"); + this.populateLineCell(lineCell, entry.lines); + } else if (entry.feature) { + let labelCell = row.querySelector(".label"); + this.populateTextLabelFromSymbol(labelCell, entry.feature); + + let iconCell = row.querySelector(".icon"); + let img = this.getIconImageFromSymbol(entry.feature); + if (img) { + iconCell.appendChild(img); + } else { + iconCell.remove(); + labelCell.setAttribute("colspan", 2); + } + } else { + return; + } + + let descriptionCell = row.querySelector(".description"); + descriptionCell.textContent = entry.description; + + return row; + } + + /** + * Returns an HTML image element that resembles the icon of the given feature + * from a symbol layer. + */ + getIconImageFromSymbol(symbol) { + let imageName = symbol.layer.layout["icon-image"]?.name; + if (!imageName) return; + + let iconSize = symbol.layer.layout["icon-size"]; + let styleImage = this._map.style.getImage(imageName); + let img = this.getImageFromStyle(styleImage, iconSize); + return img; + } + + /** + * Populates an HTML block element to resemble the text of the given feature + * from a symbol layer. + */ + populateTextLabelFromSymbol(container, symbol) { + let textField = symbol.layer.layout["text-field"]; + if (!textField) return; + + // Assume a formatted text field has only one text section. If this isn't + // formatted text, fall back to string interpolation syntax. + container.textContent = + textField.sections?.[0].text ?? + textField.replace( + /\{(\w+)\}/g, + (match, prop) => symbol.properties[prop] ?? match + ); + + // The fontstack name obscures the original font names. Look for words that + // conventionally indicate a weight or style. + let fontWeight = symbol.layer.layout["text-font"]?.[0]?.match(/\bBold\b/) + ? "bold" + : "normal"; + let fontStyle = symbol.layer.layout["text-font"]?.[0]?.match(/\bItalic\b/) + ? "italic" + : "normal"; + + // Force labels to be right-aligned if paired with an icon, which will be in + // the column to the right. + let justification = symbol.layer.layout["text-justify"] || "center"; + if (symbol.layer.layout["icon-image"]) { + justification = "right"; + } + + // Simulate a text outline by compositing shadows in four directions. + // -webkit-text-stroke won't work because it eats into the text fill. + let shadowOffset = symbol.layer.paint["text-halo-width"] ?? 0; + let haloColor = symbol.layer.paint["text-halo-color"] || "black"; + let haloBlur = symbol.layer.paint["text-halo-blur"] ?? 0; + let textShadows = [-shadowOffset, shadowOffset].flatMap((x) => + [-shadowOffset, shadowOffset].map( + (y) => `${haloColor} ${x}px ${y}px ${haloBlur}px` + ) + ); + + Object.assign(container.style, { + color: symbol.layer.paint["text-color"], + fontWeight, + fontStyle, + fontSize: `${symbol.layer.layout["text-size"] ?? 16}px`, + letterSpacing: `${symbol.layer.layout["text-letter-spacing"]}em`, + lineHeight: `${symbol.layer.layout["text-line-height"] ?? 1.2}em`, + maxWidth: "10vw", // prevent label column from taking over popup + textAlign: justification === "auto" ? "right" : justification, + textShadow: textShadows.join(", "), + textTransform: symbol.layer.layout["text-transform"], + verticalAlign: "middle", + width: `${symbol.layer.layout["text-max-width"] ?? 10}em`, + }); + } + + /** + * Returns style properties resembling the given fill and line. + */ + getSwatchStyle(fill, stroke) { + let fillColor = + fill?.layer.paint["fill-color"] || + fill?.layer.paint["fill-extrusion-color"]; + if (fillColor) { + let opacity = + fill?.layer.paint["fill-opacity"] ?? + fill?.layer.paint["fill-extrusion-opacity"] ?? + fillColor.a ?? + 1; + fillColor = `rgba(${fillColor.r * 255}, ${fillColor.g * 255}, ${ + fillColor.b * 255 + }, ${opacity})`; + } + let borderStyle = "solid"; + if (stroke?.layer.paint["line-dasharray"]) { + // Just give an idea of the outline being dashed. + borderStyle = "dashed"; + } else if (fill?.layer.paint["fill-extrusion-height"]) { + // Assume the only fill extrusion layers are for buildings and that the + // height is fixed to a small value. + borderStyle = "outset"; + } + return { + backgroundColor: fillColor, + borderColor: + stroke?.layer.paint["line-color"] || fillColor || "transparent", + borderStyle: borderStyle, + borderWidth: `${stroke?.layer.paint["line-width"] ?? 1}px`, + }; + } + + /** + * Populates the given table cell with SVG elements depicting a line. + */ + populateLineCell(cell, lineFeatures) { + let getLineWidth = (f) => { + let width = f.layer.paint["line-width"] ?? 1; + let gapWidth = f.layer.paint["line-gap-width"]; + // Round the stroke width up to one point to ensure legibility. + return Math.max( + 1 / ShieldDraw.PXR, + gapWidth ? width * 2 + gapWidth : width + ); + }; + let lineWidths = lineFeatures.map(getLineWidth); + let height = Math.max(...lineWidths); + + let svg = cell.querySelector("svg"); + svg.style.height = `${Math.ceil(height)}px`; + + for (let feature of lineFeatures) { + let line = document.createElementNS("http://www.w3.org/2000/svg", "line"); + line.setAttribute("y1", `${height / 2}px`); + line.setAttribute("y2", `${height / 2}px`); + line.setAttribute("x2", "100%"); + + // line-dasharray is measured in multiples of line-width, whereas + // stroke-dasharray is measured in pixels. + let simpleLineWidth = feature.layer.paint["line-width"] ?? 1; + let dashArray = feature.layer.paint["line-dasharray"]?.from.map( + (d) => d * simpleLineWidth + ); + + Object.assign(line.style, { + opacity: feature.layer.paint["line-opacity"] ?? 1, + stroke: feature.layer.paint["line-color"] || fillColor, + strokeDasharray: dashArray?.join(" "), + strokeWidth: getLineWidth(feature), + }); + + svg.appendChild(line); + } + } + + /** + * Returns table rows illustrating route shields. + */ + getShieldRows() { + // Query the map for rendered shield symbols in the current viewport. + let shieldFeatures = this._map.queryRenderedFeatures({ + layers: [HighwayShieldLayers.shield.id], + }); + + // Extract all the image sections embedded in the symbols and map them to + // image metadata (image names and parsed networks and route numbers). + let images = shieldFeatures + .flatMap((f) => f.layer.layout["text-field"].sections) + .filter((s) => s.image && s.image) + .map((s) => HighwayShieldLayers.parseImageName(s.image.name)); + + // Unique the images by network. + let imagesByNetwork = {}; + let unrecognizedNetworks = new Set(); + for (let image of images) { + if (!(image.network in imagesByNetwork)) { + imagesByNetwork[image.network] = { overridesByRef: {} }; + } + let networkImages = imagesByNetwork[image.network]; + + let shieldDef = ShieldDef.shields[image.network]; + if (image.ref && shieldDef?.overrideByRef?.[image.ref]) { + // Store a different image for each override in the shield definition. + if (!networkImages.overridesByRef[image.ref]) { + networkImages.overridesByRef[image.ref] = image.imageName; + } + } else if (!networkImages.ref && image.ref) { + // Store the numbered variant of a shield if required by the shield + // definition. + networkImages.ref = image.imageName; + } else if (!networkImages.noRef && !image.ref) { + // Store the unnumbered variant of a shield if required by the shield + // definition. + networkImages.noRef = image.imageName; + } + + if (!shieldDef) { + // Keep all unrecognized networks separate so we don't miss them when + // sorting the networks by the order in the shield definitions. + unrecognizedNetworks.add(image.network); + } + } + + // For each country, populate an array with shield metadata in the same + // order as in the shield definitions, appending all the unrecognized + // networks sorted in alphabetical order. + let networks = [ + ...Object.keys(ShieldDef.shields), + ...[...unrecognizedNetworks.values()].sort(), + ]; + let countries = new Set(); + let shieldRowsByCountry = {}; + let otherShieldRows = []; + for (let network of networks) { + // Skip shield definitions for which no shield is currently visible. + if (!(network in imagesByNetwork)) continue; + + // Get all the relevant images, sorted from generic to specialized. + let images = imagesByNetwork[network]; + let sortedImages = [ + images.noRef, + images.ref, + ...Object.values(images.overridesByRef), + ].filter((i) => i); + + let row = this.getShieldRow(network, sortedImages); + if (!row) continue; + + // Extract an ISO 3166-1 alpha-2 country code from the network. + // OpenMapTiles synthesizes fake networks in some countries. + let country = network + .match(/^(?:omt-)?(\w\w)(?:[-:]|$)/)?.[1] + ?.toUpperCase(); + if (country) { + if (!(country in shieldRowsByCountry)) { + shieldRowsByCountry[country] = []; + } + shieldRowsByCountry[country].push(row); + countries.add(country); + } else { + otherShieldRows.push(row); + } + } + + // Map country codes to localized names and sort the lists of networks by + // those names. + let locales = Label.getLocales(); + let countryNames = new Intl.DisplayNames(locales, { + type: "region", + }); + let sortedCountries = [...countries] + .map((code) => { + let name = countryNames.of(code) ?? code; + return { code, name }; + }) + .sort((a, b) => a.name.localeCompare(b.name, locales[0])); + + // List any network without a country code first as an international + // network. + if (otherShieldRows.length) { + sortedCountries.unshift({ code: "*", name: "International" }); + shieldRowsByCountry["*"] = otherShieldRows; + } + + let shieldRows = []; + for (let country of sortedCountries) { + if (sortedCountries.length > 1) { + let template = document + .getElementById("legend-rowgroup") + .content.cloneNode(true); + let groupRow = template.querySelector("tr"); + groupRow.querySelector("th").textContent = country.name; + shieldRows.push(groupRow); + } + + shieldRows.push(...shieldRowsByCountry[country.code]); + } + return shieldRows; + } + + /** + * Returns a table row representing a route shield. + * + * @param network The `network=*` value associated with the style images. + * @param names An array of style image names. + * @returns An HTML table row representing the route shield, or nothing if the style does not render the given network. + */ + getShieldRow(network, names) { + let images = names + .map((n) => this._map.style.getImage(n)) + .map((i) => this.getImageFromStyle(i)) + .filter((i) => i); + if (!images.length) return; + + let template = document + .getElementById("legend-row-symbol") + .content.cloneNode(true); + let row = template.querySelector("tr"); + row.dataset.network = network; + + row.querySelector(".icon").replaceChildren(...images); + + let descriptionCell = row.querySelector(".description"); + let code = document.createElement("code"); + code.textContent = network; + descriptionCell.appendChild(code); + + return row; + } + + /** + * Returns an HTML image element displaying the given style image. + * + * @param styleImage The style image to display. + * @param iconSize The size factor to apply to the width and height of the image. + * @returns An HTML image element, or nothing if the style image is merely a spacer image. + */ + getImageFromStyle(styleImage, iconSize = 1) { + let userImage = styleImage.userImage; + // Skip spacer images representing unsupported networks. + if (userImage?.width === 1 || userImage?.height === 1) return; + + // Wrap the style image's raw data as an image data buffer. Images generated + // at runtime are stored in a different property than images hard-coded in + // the spritesheet. + let imageData = new ImageData( + userImage?.data || new Uint8ClampedArray(styleImage.data.data), + userImage?.width || styleImage.data.width, + userImage?.height || styleImage.data.height + ); + + // Draw the image onto a canvas of the same size. + let canvas = document.createElement("canvas"); + canvas.width = imageData.width; + canvas.height = imageData.height; + let ctx = canvas.getContext("2d"); + ctx.putImageData(imageData, 0, 0); + + // Embed the canvas in an HTML image of the same size. + let img = new Image( + (imageData.width * iconSize) / ShieldDraw.PXR, + (imageData.height * iconSize) / ShieldDraw.PXR + ); + img.src = canvas.toDataURL("image/png"); + img.className = "shield"; + + return img; + } + + /** + * Inserts human-readable descriptions in each of the given table rows. + * + * @param rows An array of table rows containing placeholders for descriptions. + */ + async prettifyNetworkLabels(rows) { + let networkMetadata = await this.getNetworkMetadata(); + if (!networkMetadata) return; + + // If any synthesized British networks are visible, also query Wikidata for + // descriptions of those networks. + for (let row of rows) { + let network = row.dataset.network; + if (network?.startsWith("omt-gb-")) { + let ukNetworkMetadata = await this.getUKNetworkMetadata(); + Object.assign(networkMetadata, ukNetworkMetadata); + break; + } + } + + let locales = Label.getLocales(); + let languageNames = new Intl.DisplayNames(locales, { + type: "language", + }); + + // Wikidata labels are normally lowercased so that they can appear in any + // context. Convert them to sentence case for consistency with the rest of + // the legend. + let toSentenceCase = (lowerCase, locale) => + lowerCase[0].toLocaleUpperCase(locale) + lowerCase.substring(1); + for (let row of rows) { + let network = row.dataset.network; + let binding = networkMetadata[network]; + if (!binding) continue; + + let descriptionCell = row.querySelector(".description"); + + let link = document.createElement("a"); + link.href = binding.network.value; + link.target = "_blank"; + let locale = binding.networkLabel["xml:lang"]; + link.textContent = toSentenceCase(binding.networkLabel.value, locale); + if (locale) { + link.setAttribute("lang", locale); + descriptionCell.replaceChildren(link); + + if (locale.match(/^\w+/)?.[0] !== locales[0].match(/^\w+/)?.[0]) { + let languageTag = document.createElement("span"); + languageTag.className = "language"; + languageTag.textContent = languageNames.of(locale); + descriptionCell.appendChild(document.createTextNode(" ")); + descriptionCell.appendChild(languageTag); + } + } else { + descriptionCell.querySelector("code").replaceChildren(link); + } + } + } + + /** + * Returns a mapping from `network=*` values to metadata about these values from Wikidata. + */ + async getNetworkMetadata() { + if (this._networkMetadata) { + return this._networkMetadata; + } + + let url = `https://query.wikidata.org/sparql?query=${encodeURIComponent( + this.getNetworkMetadataQuery() + )}&format=json`; + const response = await fetch(url); + const json = await response.json(); + this._networkMetadata = Object.fromEntries( + json.results.bindings.map((binding) => { + return [binding.value.value, binding]; + }) + ); + return this._networkMetadata; + } + + /** + * Returns a mapping from OpenMapTiles synthesized `network=*` values to + * metadata about these values from Wikidata. + */ + async getUKNetworkMetadata() { + if (this._ukNetworkMetadata) { + return this._ukNetworkMetadata; + } + + let url = `https://query.wikidata.org/sparql?query=${encodeURIComponent( + this.getNetworkMetadataQuery("GB") + )}&format=json`; + const response = await fetch(url); + const json = await response.json(); + this._ukNetworkMetadata = Object.fromEntries( + json.results.bindings.map((binding) => { + return [binding.value.value, binding]; + }) + ); + return this._ukNetworkMetadata; + } + + purgeNetworkMetadata() { + delete this._networkMetadata; + delete this._ukNetworkMetadata; + } + + /** + * Returns the Wikidata Query Service SPARQL query for network metadata. + * + * @param region ISO 3166-1 alpha-2 country code. + */ + getNetworkMetadataQuery(region) { + let locales = Label.getLocales().join(","); + let triple, + filter = "", + bind = ""; + if (region === "GB") { + triple = + "?network wdt:P361 wd:Q115856945; p:P528 [ ps:P528 ?value; pq:P972 wd:Q110613756 ]"; + } else { + triple = "?network wdt:P1282 ?tag"; + filter = `FILTER(REGEX(?tag, "^Tag:network="))`; + bind = "BIND(SUBSTR(?tag, 13) AS ?value)"; + } + return ` +SELECT ?value ?network ?networkLabel WHERE { + ${triple}. + ${filter} + ${bind} + SERVICE wikibase:label { bd:serviceParam wikibase:language "${locales},en". } +} +ORDER BY ?value +`; + } +} diff --git a/src/js/shield_defs.js b/src/js/shield_defs.js index 66c919662..bccf5d8dc 100644 --- a/src/js/shield_defs.js +++ b/src/js/shield_defs.js @@ -3132,7 +3132,7 @@ export function loadShields(shieldImages) { Color.shields.blue ); - // Myanmar + // Malaysia shields["MY:E"] = shields["my:federal"] = hexagonVerticalShield( 3, Color.shields.yellow, @@ -3627,46 +3627,39 @@ export function loadShields(shieldImages) { Color.shields.white ); - // Kosovo - shields["XK:motorway"] = hexagonVerticalShield( - 3, - Color.shields.green, - Color.shields.white, - Color.shields.white, - 0, - 34 - ); - // OCEANIA // Australia ["ACT", "NSW", "NT", "QLD", "SA", "TAS", "VIC", "WA"].forEach( - (state_or_territory) => - ([ - shields[`AU:${state_or_territory}`], - shields[`AU:${state_or_territory}:NH`], - shields[`AU:${state_or_territory}:NR`], - shields[`AU:${state_or_territory}:S`], - shields[`AU:${state_or_territory}:T`], - shields[`AU:${state_or_territory}:ALT`], - shields[`AU:${state_or_territory}:ALT_NR`], - shields[`AU:${state_or_territory}:ALT_S`], - ] = [ + (state_or_territory) => { + shields[`AU:${state_or_territory}`] = roundedRectShield( + Color.shields.green, + Color.shields.yellow + ); + shields[`AU:${state_or_territory}:NH`] = homePlateShield( + 5, + Color.shields.green, + Color.shields.yellow + ); + shields[`AU:${state_or_territory}:NR`] = homePlateShield( + 5, + Color.shields.white, + Color.shields.black + ); + shields[`AU:${state_or_territory}:S`] = fishheadShieldBlue; + shields[`AU:${state_or_territory}:T`] = pentagonShieldBrown; + shields[`AU:${state_or_territory}:ALT`] = banneredShield( roundedRectShield(Color.shields.green, Color.shields.yellow), - homePlateShield(5, Color.shields.green, Color.shields.yellow), + ["ALT"] + ); + shields[`AU:${state_or_territory}:ALT_NR`] = banneredShield( homePlateShield(5, Color.shields.white, Color.shields.black), - fishheadShieldBlue, - pentagonShieldBrown, - banneredShield( - roundedRectShield(Color.shields.green, Color.shields.yellow), - ["ALT"] - ), - banneredShield( - homePlateShield(5, Color.shields.white, Color.shields.black), - ["ALT"] - ), + ["ALT"] + ); + shields[`AU:${state_or_territory}:ALT_S`] = [ banneredShield(fishheadShieldBlue, ["ALT"]), - ]) + ]; + } ); shields["AU:QLD:MR"] = hexagonVerticalShield( diff --git a/src/layer/aeroway.js b/src/layer/aeroway.js index b190e78be..9af7a5859 100644 --- a/src/layer/aeroway.js +++ b/src/layer/aeroway.js @@ -259,11 +259,26 @@ export const airportGate = { }, layout: { visibility: "visible", - "text-field": "{ref}", + "text-field": ["get", "ref"], "text-font": ["OpenHistorical Bold"], "text-size": 10, }, source: "openmaptiles", - metadata: {}, "source-layer": "aeroway", }; + +export const legendEntries = [ + { + description: "Civilian airport", + layers: [airportRefLabel.id, airportLabel.id], + filter: ["!=", ["get", "class"], "military"], + }, + { + description: "Military air base", + layers: [airportRefLabel.id, airportLabel.id], + filter: ["==", ["get", "class"], "military"], + }, + { description: "Runway", layers: [runway.id] }, + { description: "Taxiway", layers: [taxiway.id] }, + { description: "Gate", layers: [airportGate.id] }, +]; diff --git a/src/layer/boundary.js b/src/layer/boundary.js index b9dd9b0bf..74b18d254 100644 --- a/src/layer/boundary.js +++ b/src/layer/boundary.js @@ -31,7 +31,6 @@ export const countyCasing = { type: "line", paint: { "line-color": Color.borderCasing, - "line-dasharray": [1], "line-width": { stops: [ [11, 5], @@ -89,7 +88,6 @@ export const stateCasing = { [7, `hsl(${Color.hueBorderCasing}, 30%, 90%)`], ], }, - "line-dasharray": [1], "line-width": { base: 1.2, stops: [ @@ -243,3 +241,27 @@ export const country = { source: "openmaptiles", "source-layer": "boundary", }; + +export const legendEntries = [ + { + description: "Country or dependency", + layers: [country.id, countryCasing.id], + }, + { + description: "State or province", + layers: [state.id, stateCasing.id], + }, + { + description: "County or county-equivalent", + layers: [county.id, countyCasing.id], + }, + { + description: "City, town, or village", + layers: [city.id], + }, + { + description: "Disputed border", + layers: [countryCasing.id, stateCasing.id, countyCasing.id], + filter: ["==", ["get", "disputed"], 1], + }, +]; diff --git a/src/layer/building.js b/src/layer/building.js index ac772887b..e395baf07 100644 --- a/src/layer/building.js +++ b/src/layer/building.js @@ -16,11 +16,16 @@ export const building = { "fill-extrusion-height": 3, "fill-extrusion-opacity": 0.85, }, - // filter: ["all", ["!=", "intermittent", 1], ["!=", "brunnel", "tunnel"]], layout: { visibility: "visible", }, source: "openmaptiles", - metadata: {}, "source-layer": "building", }; + +export const legendEntries = [ + { + description: "Building", + layers: [building.id], + }, +]; diff --git a/src/layer/construction.js b/src/layer/construction.js index 2aad62338..7f653ba0a 100644 --- a/src/layer/construction.js +++ b/src/layer/construction.js @@ -1,3 +1,5 @@ +"use strict"; + const majorConstruction = [ "match", ["get", "class"], @@ -54,3 +56,10 @@ export const road = { ], }, }; + +export const legendEntries = [ + { + description: "Road under construction", + layers: [road.id], + }, +]; diff --git a/src/layer/ferry.js b/src/layer/ferry.js index 57f123c4f..6aa8a6ca1 100644 --- a/src/layer/ferry.js +++ b/src/layer/ferry.js @@ -24,3 +24,10 @@ export const ferry = { source: "openmaptiles", "source-layer": "transportation", }; + +export const legendEntries = [ + { + description: "Ferry line", + layers: [ferry.id], + }, +]; diff --git a/src/layer/highway_exit.js b/src/layer/highway_exit.js index 8355c7ab7..6f995becc 100644 --- a/src/layer/highway_exit.js +++ b/src/layer/highway_exit.js @@ -1,3 +1,5 @@ +"use strict"; + export const exits = { id: "highway_exit", type: "symbol", @@ -21,3 +23,10 @@ export const exits = { "text-halo-width": 0.75, }, }; + +export const legendEntries = [ + { + description: "Freeway or expressway exit", + layers: [exits.id], + }, +]; diff --git a/src/layer/highway_shield.js b/src/layer/highway_shield.js index 362a666cd..968e024e3 100644 --- a/src/layer/highway_shield.js +++ b/src/layer/highway_shield.js @@ -1,33 +1,48 @@ "use strict"; -function routeConcurrency(num) { +export const namedRouteNetworks = [ + "US:KY:Parkway", + "US:NY:Parkway", + "US:TX:Fort_Bend:FBCTRA", + "US:TX:Harris:HCTRA", +]; + +export function getImageNameExpression(routeIndex) { return [ - "case", - ["!=", ["get", "route_" + num], null], + "concat", + "shield\n", + ["get", "route_" + routeIndex], [ - "image", - [ - "concat", - "shield\n", - ["get", "route_" + num], - [ - "match", - ["get", "route_" + num], - [ - "US:KY:Parkway=", - "US:NY:Parkway=", - "US:TX:Fort_Bend:FBCTRA=", - "US:TX:Harris:HCTRA=", - ], - ["concat", "\n", ["get", "name"]], - "", - ], - ], + "match", + ["get", "route_" + routeIndex], + namedRouteNetworks.map((n) => n + "="), + ["concat", "\n", ["get", "name"]], + "", ], + ]; +} + +function routeConcurrency(routeIndex) { + return [ + "case", + ["!=", ["get", "route_" + routeIndex], null], + ["image", getImageNameExpression(routeIndex)], ["literal", ""], ]; } +/** + * Returns a structured representation of the given image name. + * + * @param name An image name in the format returned by `routeConcurrency`. + */ +export function parseImageName(imageName) { + let lines = imageName.split("\n"); + let [, network, ref] = lines[1].match(/^(.*?)=(.*)/) || []; + let name = lines[2]; + return { imageName, network, ref, name }; +} + let shieldTextField = ["format"]; for (var i = 1; i <= 6; i++) { shieldTextField.push(routeConcurrency(i)); diff --git a/src/layer/landuse.js b/src/layer/landuse.js index 7536dbc99..added57e8 100644 --- a/src/layer/landuse.js +++ b/src/layer/landuse.js @@ -26,3 +26,10 @@ export const urbanizedArea = { source: "openmaptiles", "source-layer": "landuse", }; + +export const legendEntries = [ + { + description: "Urban area", + layers: [urbanizedArea.id], + }, +]; diff --git a/src/layer/park.js b/src/layer/park.js index aaf34cf1f..2c151e342 100644 --- a/src/layer/park.js +++ b/src/layer/park.js @@ -9,11 +9,7 @@ export const fill = { paint: { "fill-color": Color.parkFill, }, - layout: { - visibility: "visible", - }, source: "openmaptiles", - metadata: {}, "source-layer": "park", }; @@ -23,9 +19,6 @@ export const outline = { paint: { "line-color": Color.parkOutline, }, - layout: { - visibility: "visible", - }, source: "openmaptiles", metadata: {}, "source-layer": "park", @@ -42,7 +35,6 @@ export const label = { "text-halo-width": 1, }, layout: { - visibility: "visible", "text-field": Label.localizedName, "text-font": ["OpenHistorical Bold"], "text-size": 10, @@ -53,52 +45,29 @@ export const label = { }; export const parkFill = { + ...fill, id: "park-fill", - type: "fill", filter: ["==", ["get", "subclass"], "park"], - paint: { - "fill-color": Color.parkFill, - }, - layout: { - visibility: "visible", - }, - source: "openmaptiles", - metadata: {}, "source-layer": "landcover", }; export const parkOutline = { + ...outline, id: "park-outline", - type: "line", filter: ["==", ["get", "subclass"], "park"], - paint: { - "line-color": Color.parkOutline, - }, - layout: { - visibility: "visible", - }, - source: "openmaptiles", - metadata: {}, "source-layer": "landcover", }; export const parkLabel = { + ...label, id: "park-label", - type: "symbol", filter: ["==", ["get", "class"], "park"], - paint: { - "text-color": Color.parkLabel, - "text-halo-blur": 1, - "text-halo-color": Color.parkLabelHalo, - "text-halo-width": 1, - }, - layout: { - visibility: "visible", - "text-field": Label.localizedName, - "text-font": ["OpenHistorical Bold"], - "text-size": 10, - "symbol-sort-key": ["get", "rank"], - }, - source: "openmaptiles", "source-layer": "poi", }; + +export const legendEntries = [ + { + description: "Park", + layers: [fill.id, outline.id, parkFill.id, parkOutline.id], + }, +]; diff --git a/src/layer/place.js b/src/layer/place.js index 07519476b..45cd8cd42 100644 --- a/src/layer/place.js +++ b/src/layer/place.js @@ -424,3 +424,47 @@ export const continent = { maxzoom: 1, "source-layer": "place", }; + +const populatedPlaceLayers = [village.id, town.id, city.id]; +const nonCapitalFilter = ["!", ["has", "capital"]]; + +export const legendEntries = [ + { + description: "Continent", + layers: [continent.id], + }, + { + description: "Country or dependency", + layers: [countryOther.id, country3.id, country2.id, country1.id], + }, + { + description: "State or province", + layers: [state.id], + }, + { + description: "Large city", + layers: [city.id], + filter: nonCapitalFilter, + }, + { description: "Town", layers: [town.id], filter: nonCapitalFilter }, + { + description: "Small village", + layers: [village.id], + filter: nonCapitalFilter, + }, + { + description: "National capital", + layers: populatedPlaceLayers, + filter: ["==", ["get", "capital"], 2], + }, + { + description: "Regional capital", + layers: populatedPlaceLayers, + filter: ["==", ["get", "capital"], 3], + }, + { + description: "State or provincial capital", + layers: populatedPlaceLayers, + filter: ["==", ["get", "capital"], 4], + }, +]; diff --git a/src/layer/rail.js b/src/layer/rail.js index 1f4fd3827..a667462e8 100644 --- a/src/layer/rail.js +++ b/src/layer/rail.js @@ -59,6 +59,7 @@ var defRail = { var serviceSelector = ["match", ["get", "service"], ["siding", "spur", "yard"]]; var isService = [...serviceSelector, true, false]; var isNotService = [...serviceSelector, false, true]; +let isNotCrossover = ["!=", ["get", "service"], "crossover"]; var lineColor = [ "match", @@ -200,7 +201,7 @@ class Railway { if (this.constraints != null) { layer.filter.push(this.constraints); } - layer.filter.push(["!=", ["get", "service"], "crossover"]); + layer.filter.push(isNotCrossover); return layer; }; } @@ -422,6 +423,35 @@ class LightRailTramServiceTunnel extends LightRailTramService { } } +export function getLayerSeparatedBridgeLayers(bridgeLayers) { + let layers = []; + + //Render bridge without layer on the lowest bridge layer + bridgeLayers.forEach((layer) => + layers.push( + Util.filteredClone(layer, ["!", ["has", "layer"]], "_layer_bottom") + ) + ); + + //One layer at a time to handle stacked bridges + for (let i = 1; i <= 4; i++) { + bridgeLayers.forEach((layer) => layers.push(Util.restrictLayer(layer, i))); + } + + //If layer is more than 5, just give up and render on a single layer. + bridgeLayers.forEach((layer) => + layers.push( + Util.filteredClone( + layer, + [">=", ["coalesce", ["get", "layer"], 0], 5], + "_layer_top" + ) + ) + ); + + return layers; +} + export const railway = new Railway(); export const railwayBridge = new RailwayBridge(); export const railwayTunnel = new RailwayTunnel(); @@ -453,3 +483,81 @@ export const lightRailTramServiceTunnel = new LightRailTramServiceTunnel(); export const funicular = new Funicular(); export const funicularBridge = new FunicularBridge(); export const funicularTunnel = new FunicularTunnel(); + +const isGenericRail = ["==", ["get", "subclass"], "rail"]; +const isStandardGauge = ["!=", ["get", "subclass"], "narrow_gauge"]; +const isNarrowGauge = ["==", ["get", "subclass"], "narrow_gauge"]; +const isSubway = ["==", ["get", "subclass"], "subway"]; +const isLightRail = ["==", ["get", "subclass"], "light_rail"]; +const isTram = ["==", ["get", "subclass"], "tram"]; + +export const legendEntries = [ + { + description: "Mainline track", + layers: [rail.dashes().id, railway.fill().id], + filter: ["all", isGenericRail, isNotService, isNotCrossover], + }, + { + description: "Siding, spur, or yard track", + layers: [railService.dashes().id, railway.fill().id], + filter: ["all", isGenericRail, isService], + }, + { + description: "Narrow-gauge mainline track", + layers: [narrowGauge.dashes().id, railway.fill().id], + filter: ["all", isNarrowGauge, isGenericRail, isNotService, isNotCrossover], + }, + { + description: "Narrow-gauge siding, spur, or yard track", + layers: [narrowGaugeService.dashes().id, railway.fill().id], + filter: ["all", isNarrowGauge, isGenericRail, isService], + }, + { + description: "Subway line", + layers: [railwayTunnel.fill().id, railway.fill().id], + filter: ["all", isSubway, isNotService, isNotCrossover], + }, + { + description: "Subway siding or yard track", + layers: [railwayTunnel.fill().id, railway.fill().id], + filter: ["all", isSubway, isService], + }, + { + description: "Monorail line", + layers: [ + ...getLayerSeparatedBridgeLayers([railwayBridge.fill()]).map((l) => l.id), + railway.fill().id, + ], + filter: [ + "all", + ["==", ["get", "subclass"], "monorail"], + isNotService, + isNotCrossover, + ], + }, + { + description: "Light rail line", + layers: [lightRailTram.dashes().id, railway.fill().id], + filter: ["all", isLightRail, isNotService, isNotCrossover], + }, + { + description: "Light rail siding or yard track", + layers: [lightRailTramService.dashes().id, railway.fill().id], + filter: ["all", isLightRail, isService], + }, + { + description: "Streetcar line", + layers: [lightRailTram.dashes().id, railway.fill().id], + filter: ["all", isTram, isNotService, isNotCrossover], + }, + { + description: "Streetcar siding or yard track", + layers: [lightRailTramService.dashes().id, railway.fill().id], + filter: ["all", isTram, isService], + }, + { + description: "Funicular or inclined elevator", + layers: [funicular.dashes().id, railway.fill().id], + filter: ["==", ["get", "subclass"], "funicular"], + }, +]; diff --git a/src/layer/road.js b/src/layer/road.js index 0c2469af5..c71a5c1c0 100644 --- a/src/layer/road.js +++ b/src/layer/road.js @@ -51,6 +51,7 @@ const smallServiceSelector = [ ["get", "service"], ["parking_aisle", "driveway"], ]; +const isUnpaved = ["==", ["get", "surface"], "unpaved"]; function combineConstraints(constraint1, constraint2) { if (constraint1 == null) { @@ -472,11 +473,7 @@ class Road { Math.max(this.maxZoomCasing, this.maxZoomFill), this.constraints ); - layer.filter = combineConstraints(layer.filter, [ - "==", - ["get", "surface"], - "unpaved", - ]); + layer.filter = combineConstraints(layer.filter, isUnpaved); layer.layout = { "line-cap": "butt", "line-join": "round", @@ -1198,3 +1195,95 @@ export const secondaryLinkBridge = new SecondaryLinkBridge(); export const secondaryLinkTollBridge = new SecondaryLinkTollBridge(); export const tertiaryLinkBridge = new TertiaryLinkBridge(); export const tertiaryLinkTollBridge = new TertiaryLinkTollBridge(); + +const normalRoadLayers = [ + motorway.fill().id, + motorway.casing().id, + trunk.casing().id, + primaryToll.fill().id, + secondaryToll.fill().id, + tertiaryToll.fill().id, + minorToll.fill().id, + roadSimpleCasing.casing().id, +]; + +export const legendEntries = [ + { + description: "Freeway (controlled access, divided)", + layers: [motorway.fill().id, motorway.casing().id], + filter: ["all", isNotToll, [">", opacity, 0]], + }, + { + description: "Expressway (limited access, divided)", + layers: [ + roadSimpleFill.fill().id, + roadSimpleCasing.casing().id, + primaryExpressway.casing().id, + primaryExpressway.casing().id, + secondaryExpressway.casing().id, + tertiaryExpressway.casing().id, + ], + filter: ["all", isExpressway, isNotToll], + }, + { + description: "Principal highway", + layers: [trunk.casing().id], + filter: isNotToll, + }, + { + description: "Major arterial road", + layers: [primary.fill().id, roadSimpleCasing.casing().id], + filter: ["==", getClass, "primary"], + }, + { + description: "Minor arterial road", + layers: [secondary.fill().id, roadSimpleCasing.casing().id], + filter: ["==", getClass, "secondary"], + }, + { + description: "Collector road", + layers: [tertiary.fill().id, roadSimpleCasing.casing().id], + filter: ["==", getClass, "tertiary"], + }, + { + description: "Local road", + layers: [minor.fill().id, roadSimpleCasing.casing().id], + filter: ["match", getClass, ["minor", "service"], true, false], + }, + { + description: "Driveway or parking aisle", + layers: [minor.fill().id, roadSimpleCasing.casing().id], + filter: [ + "all", + ["==", getClass, "service"], + [...smallServiceSelector, true, false], + ], + }, + { + description: "Toll road", + layers: [ + motorway.fill().id, + motorway.casing().id, + trunk.casing().id, + primaryToll.fill().id, + secondaryToll.fill().id, + tertiaryToll.fill().id, + minorToll.fill().id, + roadSimpleCasing.casing().id, + ], + filter: isToll, + }, + { + description: "Unpaved road", + layers: [ + road.surface().id, + trunk.casing().id, + primary.fill().id, + secondary.fill().id, + tertiary.fill().id, + minor.fill().id, + roadSimpleCasing.casing().id, + ], + filter: isUnpaved, + }, +]; diff --git a/src/layer/water.js b/src/layer/water.js index a088ddfc9..89219623f 100644 --- a/src/layer/water.js +++ b/src/layer/water.js @@ -222,3 +222,61 @@ export const waterPointLabel = { }, paint: labelPaintProperties, }; + +export const legendEntries = [ + { + description: "Ocean, sea, or bay", + layers: [water.id, waterLine.id], + filter: ["==", ["get", "class"], "ocean"], + }, + { + description: "Lake or pond", + layers: [water.id, waterLine.id], + filter: [ + "all", + ["==", ["get", "class"], "lake"], + ["!=", ["get", "intermittent"], 1], + ], + }, + { + description: "Intermittent lake or pond", + layers: [water.id, waterLineIntermittent.id], + filter: [ + "all", + ["==", ["get", "class"], "lake"], + ["==", ["get", "intermittent"], 1], + ], + }, + { + description: "River", + layers: [waterway.id], + filter: [ + "all", + ["==", ["get", "class"], "river"], + ["!=", ["get", "intermittent"], 1], + ], + }, + { + description: "Canal", + layers: [waterway.id], + filter: [ + "all", + ["==", ["get", "class"], "canal"], + ["!=", ["get", "intermittent"], 1], + ], + }, + { + description: "Creek", + layers: [waterway.id], + filter: [ + "all", + ["==", ["get", "class"], "stream"], + ["!=", ["get", "intermittent"], 1], + ], + }, + { + description: "Intermittent river or creek", + layers: [waterway.id, waterwayIntermittent.id], + filter: ["==", ["get", "intermittent"], 1], + }, +]; diff --git a/test/spec/highway_shield.js b/test/spec/highway_shield.js new file mode 100644 index 000000000..7f83d28ad --- /dev/null +++ b/test/spec/highway_shield.js @@ -0,0 +1,65 @@ +"use strict"; + +import chai, { expect } from "chai"; +import * as HighwayShieldLayers from "../../src/layer/highway_shield.js"; +import { expression } from "@maplibre/maplibre-gl-style-spec"; + +function expressionContext(properties) { + return { + properties: () => properties, + }; +} + +describe("highway_shield", function () { + describe("#parseImageName", function () { + let evaluatedExpression = (properties) => + expression + .createExpression(HighwayShieldLayers.getImageNameExpression(1)) + .value.expression.evaluate(expressionContext(properties)); + + let expectImageName = (network, ref, name, expectedImageName) => { + let properties = { + route_1: `${network || ""}=${ref || ""}`, + name: name || null, + }; + let evaluated = evaluatedExpression(properties); + let expectedProperties = { + imageName: expectedImageName, + network: network || "", + ref: ref || "", + name: + !ref && HighwayShieldLayers.namedRouteNetworks.includes(network) + ? name + : undefined, + }; + expect(HighwayShieldLayers.parseImageName(evaluated)).to.be.deep.equal( + expectedProperties + ); + }; + + it("parses an image name for a numbered route", function () { + expectImageName("NET", "REF", undefined, "shield\nNET=REF"); + expectImageName("NET", "REF", "NAME", "shield\nNET=REF"); + }); + it("parses an image name for an unnumbered route", function () { + expectImageName("NET", undefined, undefined, "shield\nNET="); + }); + it("parses an image name for a named route", function () { + expectImageName( + "US:KY:Parkway", + undefined, + "NAME", + "shield\nUS:KY:Parkway=\nNAME" + ); + expectImageName( + "US:KY:Parkway", + "REF", + "NAME", + "shield\nUS:KY:Parkway=REF" + ); + }); + it("parses an image name for a network-independent route", function () { + expectImageName(undefined, "REF", "NAME", "shield\n=REF"); + }); + }); +});