Style
Colors
Attributes
Display GeoJSON files as meshes and symbols.
The FeatureCollection
entity can display simple features as meshes, that do not require a map to display. The benefits are a reduced memory usage and lower latency when updating the styles. The entity supports fully dynamic fill, stroke and point styles
. Whenever the style of a feature changes, call updateStyles()
with the updated object(s).
import colormap from "colormap";
import { Vector3, Color, MathUtils } from "three";
import { MapControls } from "three/examples/jsm/controls/MapControls.js";
import GeoJSON from "ol/format/GeoJSON.js";
import VectorSource from "ol/source/Vector.js";
import { createXYZ } from "ol/tilegrid.js";
import { tile } from "ol/loadingstrategy.js";
import Instance from "@giro3d/giro3d/core/Instance.js";
import Extent from "@giro3d/giro3d/core/geographic/Extent.js";
import Inspector from "@giro3d/giro3d/gui/Inspector.js";
import FeatureCollection from "@giro3d/giro3d/entities/FeatureCollection.js";
function bindColorPicker(id, onChange) {
const element = document.getElementById(id);
if (!(element instanceof HTMLInputElement)) {
throw new Error(
"invalid binding element: expected HTMLInputElement, got: " +
element.constructor.name,
);
}
element.oninput = function oninput() {
// Let's change the classification color with the color picker value
const hexColor = element.value;
onChange(new Color(hexColor));
};
const externalFunction = (v) => {
element.value = `#${new Color(v).getHexString()}`;
onChange(element.value);
};
return [externalFunction, new Color(element.value), element];
}
function bindSlider(id, onChange) {
const element = document.getElementById(id);
if (!(element instanceof HTMLInputElement)) {
throw new Error(
"invalid binding element: expected HTMLInputElement, got: " +
element.constructor.name,
);
}
element.oninput = function oninput() {
onChange(element.valueAsNumber);
};
const setValue = (v, min, max, step) => {
if (min != null && max != null) {
element.min = min.toString();
element.max = max.toString();
if (step != null) {
element.step = step;
}
}
element.valueAsNumber = v;
onChange(element.valueAsNumber);
};
const initialValue = element.valueAsNumber;
return [setValue, initialValue, element];
}
function makeColorRamp(
preset,
discrete = false,
invert = false,
mirror = false,
) {
let nshades = discrete ? 10 : 256;
const values = colormap({ colormap: preset, nshades });
const colors = values.map((v) => new Color(v));
if (invert) {
colors.reverse();
}
if (mirror) {
const mirrored = [...colors, ...colors.reverse()];
return mirrored;
}
return colors;
}
function bindDropDown(id, onChange) {
const element = document.getElementById(id);
if (!(element instanceof HTMLSelectElement)) {
throw new Error(
"invalid binding element: expected HTMLSelectElement, got: " +
element.constructor.name,
);
}
element.onchange = () => {
onChange(element.value);
};
const callback = (v) => {
element.value = v;
onChange(element.value);
};
const setOptions = (options) => {
const items = options.map(
(opt) =>
`<option value=${opt.id} ${opt.selected ? "selected" : ""}>${opt.name}</option>`,
);
element.innerHTML = items.join("\n");
};
return [callback, element.value, element, setOptions];
}
const instance = new Instance({
target: "view",
crs: "EPSG:3857",
backgroundColor: null,
});
const extent = new Extent(
"EPSG:3857",
-20037508.342789244,
20037508.342789244,
-20037508.342789244,
20037508.342789244,
);
const colors = {
"North America": "#b5a98f",
"South America": "#adc78b",
Asia: "#d4d496",
Africa: "#db95a5",
Oceania: "#c49856",
Europe: "#ac96d4",
};
let colorMode = "continent";
let lineWidth = 1;
let fillOpacity = 1;
let imageSize = 32;
let strokeOpacity = 1;
const getContinentColor = (feature) => {
const properties = feature.getProperties();
const continent = properties["continent"];
return colors[continent];
};
const populationColorMap = makeColorRamp("bluered");
const getPopulationColor = (feature) => {
const properties = feature.getProperties();
const population = properties["pop_est"];
const colorIndex = MathUtils.clamp(
Math.log(population * 0.0001) * 20,
0,
255,
);
return populationColorMap[Math.round(colorIndex)];
};
const gdpColorRamp = makeColorRamp("hot", false, true);
const getGdpColor = (feature) => {
const properties = feature.getProperties();
const gdp = properties["gdp_md"];
const colorIndex = MathUtils.clamp(Math.log(gdp * 0.0001) * 30, 0, 255);
return gdpColorRamp[Math.round(colorIndex)];
};
const countryStyle = (feature) => {
const properties = feature.getProperties();
let fillColor;
let activeColor;
switch (colorMode) {
case "continent":
fillColor = getContinentColor(feature);
activeColor = "yellow";
break;
case "population":
fillColor = getPopulationColor(feature);
activeColor = "yellow";
break;
case "gdp":
fillColor = getGdpColor(feature);
activeColor = "cyan";
break;
}
const hovered = properties.hovered ?? false;
const clicked = properties.clicked ?? false;
const fill = clicked ? activeColor : fillColor;
return {
fill: {
color: fill,
depthTest: false,
renderOrder: 1,
opacity: fillOpacity,
},
stroke: {
opacity: strokeOpacity,
color: clicked || hovered ? activeColor : "black",
renderOrder: 2, // To ensure lines are displayed on top of surfaces
lineWidth: clicked ? lineWidth * 2 : lineWidth,
depthTest: false,
},
};
};
const countries = new FeatureCollection({
source: new VectorSource({
format: new GeoJSON(),
url: "https://3d.oslandia.com/giro3d/vectors/countries.geojson",
strategy: tile(createXYZ({ tileSize: 512 })),
}),
extent,
style: countryStyle,
minLevel: 0,
maxLevel: 0,
});
countries.name = "countries";
instance.add(countries);
const capitalStyle = (feature) => {
const image = "https://3d.oslandia.com/giro3d/images/capital.webp";
const clicked = feature.get("clicked");
const hovered = feature.get("hovered");
return {
point: {
color: clicked ? "yellow" : hovered ? "orange" : "white",
pointSize: clicked ? imageSize * 1.5 : imageSize,
image,
renderOrder: clicked ? 4 : 3,
},
};
};
const capitals = new FeatureCollection({
source: new VectorSource({
format: new GeoJSON(),
url: "https://3d.oslandia.com/giro3d/vectors/capitals.geojson",
strategy: tile(createXYZ({ tileSize: 512 })),
}),
extent,
style: capitalStyle,
minLevel: 0,
maxLevel: 0,
});
capitals.name = "capitals";
instance.add(capitals);
instance.view.camera.position.set(0, 5500000, 50000000);
const lookAt = new Vector3(0, 5500000 + 1, 0);
instance.view.camera.lookAt(lookAt);
const controls = new MapControls(instance.view.camera, instance.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.4;
controls.target.copy(lookAt);
controls.saveState();
instance.view.setControls(controls);
// information on click
const resultTable = document.getElementById("results");
function truncate(value, length) {
if (value == null) {
return null;
}
const text = `${value}`;
if (text.length < length) {
return text;
}
return text.substring(0, length) + "…";
}
const filteredAttributes = [
"country",
"city",
"continent",
"name",
"gdp_md",
"pop_est",
];
const gdpFormatter = new Intl.NumberFormat(undefined, {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
const popFormatter = new Intl.NumberFormat(undefined, {
style: "decimal",
});
function formatValue(attribute, value) {
switch (attribute) {
case "gdp_md":
return gdpFormatter.format(value);
case "pop_est":
return popFormatter.format(value);
default:
return truncate(value, 18);
}
}
function fillTable(objects) {
resultTable.innerHTML = "";
document.getElementById("attributes").style.display =
objects.length > 0 ? "block" : "none";
for (const obj of objects) {
if (!obj.userData.feature) {
continue;
}
const p = obj.userData.feature.getProperties();
const entries = [];
for (const [key, value] of Object.entries(p)) {
if (filteredAttributes.includes(key)) {
const entry = `<tr>
<td title="${key}"><code>${truncate(key, 12)}</code></td>
<td title="${value}">${formatValue(key, value) ?? "<code>null</code>"}</td>
</tr>`;
entries.push(entry);
}
}
resultTable.innerHTML += `
<table class="table table-sm">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Value</th>
</tr>
</thead>
<tbody>
${entries.join("")}
</tbody>
</table>
`;
}
}
const previousHovered = [];
const previousClicked = [];
const objectsToUpdate = [];
function pick(e, click) {
const pickedObjects = instance.pickObjectsAt(e, {
where: [capitals, countries],
});
if (click) {
previousClicked.forEach((obj) =>
obj.userData.feature.set("clicked", false),
);
} else {
previousHovered.forEach((obj) =>
obj.userData.feature.set("hovered", false),
);
}
const property = click ? "clicked" : "hovered";
objectsToUpdate.length = 0;
if (pickedObjects.length > 0) {
const picked = pickedObjects[0];
const obj = picked.object;
const { feature } = obj.userData;
feature.set(property, true);
objectsToUpdate.push(obj);
}
if (click) {
fillTable(objectsToUpdate);
}
// To avoid updating all the objects and lose a lot of performance,
// we only update the objects that have changed.
const updatedObjects = [
...previousHovered,
...previousClicked,
...objectsToUpdate,
];
if (click) {
previousClicked.splice(0, previousClicked.length, ...objectsToUpdate);
} else {
previousHovered.splice(0, previousHovered.length, ...objectsToUpdate);
}
if (updatedObjects.length > 0) {
countries.updateStyles(updatedObjects);
capitals.updateStyles(updatedObjects);
}
}
const hover = (e) => pick(e, false);
const click = (e) => pick(e, true);
instance.domElement.addEventListener("mousemove", hover);
instance.domElement.addEventListener("click", click);
for (const continent of Object.keys(colors)) {
let timeout;
const [setColor] = bindColorPicker(continent, (c) => {
colors[continent] = c;
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => countries.updateStyles(), 16);
});
setColor(colors[continent]);
}
const [setLineWidth] = bindSlider("line-width", (v) => {
lineWidth = v;
countries.updateStyles();
});
setLineWidth(lineWidth);
const [setStrokeOpacity] = bindSlider("stroke-opacity", (v) => {
strokeOpacity = v;
countries.updateStyles();
});
setStrokeOpacity(strokeOpacity);
const [setFillOpacity] = bindSlider("fill-opacity", (v) => {
fillOpacity = v;
countries.updateStyles();
});
setFillOpacity(fillOpacity);
const [setImageSize] = bindSlider("image-size", (v) => {
imageSize = v;
capitals.updateStyles();
});
setImageSize(imageSize);
bindDropDown("color-mode", (mode) => {
colorMode = mode;
countries.updateStyles();
document.getElementById("colors").style.display =
colorMode === "continent" ? "block" : "none";
});
Inspector.attach("inspector", instance);
<!doctype html>
<html lang="en">
<head>
<title>Undraped vectors</title>
<meta charset="UTF-8" />
<meta name="name" content="undraped_vectors" />
<meta
name="description"
content="Display GeoJSON files as meshes and symbols."
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="https://giro3d.org/images/favicon.svg" />
<link
href="https://giro3d.org/assets/bootstrap-custom.css"
rel="stylesheet"
/>
<script src="https://giro3d.org/assets/bootstrap.bundle.min.js"></script>
<link
rel="stylesheet"
type="text/css"
href="https://giro3d.org/latest/examples/css/example.css"
/>
<style>
#view canvas {
background: rgb(132, 170, 182);
background: radial-gradient(
circle,
rgba(132, 170, 182, 1) 0%,
rgba(37, 44, 48, 1) 100%
);
}
</style>
</head>
<body>
<div id="view" class="m-0 p-0 w-100 h-100"></div>
<div
id="inspector"
class="position-absolute top-0 start-0 mh-100 overflow-auto"
></div>
<div class="side-pane-with-status-bar pe-none" style="width: 20rem">
<!--Parameters -->
<div class="card">
<div class="card-header">Style</div>
<div class="card-body">
<div class="input-group mb-3">
<label class="input-group-text" for="color-mode">Color</label>
<select class="form-select" id="color-mode" autocomplete="off">
<option value="continent" selected>Per continent</option>
<option value="population">Population</option>
<option value="gdp">GDP</option>
</select>
</div>
<div class="card mb-3" id="colors">
<div class="card-header">Colors</div>
<div class="card-body">
<!-- North America -->
<label class="form-check-label w-100 mb-2" for="North America">
<div class="row">
<div class="col">North America</div>
<div class="col">
<input
type="color"
class="form-control form-control-color float-end h-100 w-100"
id="North America"
value="#2978b4"
title="color"
/>
</div>
</div>
</label>
<!-- South America -->
<label class="form-check-label w-100 mb-2" for="South America">
<div class="row">
<div class="col">South America</div>
<div class="col">
<input
type="color"
class="form-control form-control-color float-end h-100 w-100"
id="South America"
value="#2978b4"
title="color"
/>
</div>
</div>
</label>
<!-- Asia -->
<label class="form-check-label w-100 mb-2" for="Asia">
<div class="row">
<div class="col">Asia</div>
<div class="col">
<input
type="color"
class="form-control form-control-color float-end h-100 w-100"
id="Asia"
value="#2978b4"
title="color"
/>
</div>
</div>
</label>
<!-- Europe -->
<label class="form-check-label w-100 mb-2" for="Europe">
<div class="row">
<div class="col">Europe</div>
<div class="col">
<input
type="color"
class="form-control form-control-color float-end h-100 w-100"
id="Europe"
value="#2978b4"
title="color"
/>
</div>
</div>
</label>
<!-- Africa -->
<label class="form-check-label w-100 mb-2" for="Africa">
<div class="row">
<div class="col">Africa</div>
<div class="col">
<input
type="color"
class="form-control form-control-color float-end h-100 w-100"
id="Africa"
value="#2978b4"
title="color"
/>
</div>
</div>
</label>
<!-- Oceania -->
<label class="form-check-label w-100 mb-2" for="Oceania">
<div class="row">
<div class="col">Oceania</div>
<div class="col">
<input
type="color"
class="form-control form-control-color float-end h-100 w-100"
id="Oceania"
value="#2978b4"
title="color"
/>
</div>
</div>
</label>
</div>
</div>
<!-- Line width slider -->
<div class="row">
<div class="col">
<label for="line-width" class="form-label">Line width</label>
</div>
<div class="col">
<input
type="range"
min="0"
max="20"
step="1"
value="2"
class="form-range"
id="line-width"
autocomplete="off"
/>
</div>
</div>
<!-- Image size -->
<div class="row">
<div class="col">
<label for="image-size" class="form-label">Image size</label>
</div>
<div class="col">
<input
type="range"
min="8"
max="256"
step="1"
value="64"
class="form-range"
id="image-size"
autocomplete="off"
/>
</div>
</div>
<!-- Stroke opacity -->
<div class="row">
<div class="col">
<label for="stroke-opacity" class="form-label"
>Stroke opacity</label
>
</div>
<div class="col">
<input
type="range"
min="0"
max="1"
step="0.01"
value="1"
class="form-range"
id="stroke-opacity"
autocomplete="off"
/>
</div>
</div>
<!-- Fill opacity -->
<div class="row">
<div class="col">
<label for="fill-opacity" class="form-label">Fill opacity</label>
</div>
<div class="col">
<input
type="range"
min="0"
max="1"
step="0.01"
value="1"
class="form-range"
id="fill-opacity"
autocomplete="off"
/>
</div>
</div>
</div>
</div>
<div class="card mt-3" id="attributes" style="display: none">
<div class="card-header">Attributes</div>
<div class="card-body">
<!-- Result table -->
<div id="results"></div>
</div>
</div>
</div>
<script type="module" src="index.js"></script>
<script>
/* activate popovers */
const popoverTriggerList = [].slice.call(
document.querySelectorAll('[data-bs-toggle="popover"]'),
);
popoverTriggerList.map(
// bootstrap is used as script in the template, disable warning about undef
// eslint-disable-next-line no-undef
(popoverTriggerEl) =>
new bootstrap.Popover(popoverTriggerEl, {
trigger: "hover",
placement: "left",
content: document.getElementById(
popoverTriggerEl.getAttribute("data-bs-content"),
).innerHTML,
html: true,
}),
);
</script>
</body>
</html>
{
"name": "undraped_vectors",
"dependencies": {
"colormap": "^2.3.2",
"@giro3d/giro3d": "0.40.0"
},
"devDependencies": {
"vite": "^3.2.3"
},
"scripts": {
"start": "vite",
"build": "vite build"
}
}