Display GeoJSON files as meshes and symbols.

Style
Colors
100%

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).

index.js
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);
index.html
<!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>
package.json
{
    "name": "undraped_vectors",
    "dependencies": {
        "colormap": "^2.3.2",
        "@giro3d/giro3d": "0.40.0"
    },
    "devDependencies": {
        "vite": "^3.2.3"
    },
    "scripts": {
        "start": "vite",
        "build": "vite build"
    }
}