Display GeoJSON files as meshes and symbols.

Giro3D version
THREE.js version
OpenLayers version
CRS
Memory usage (CPU)
Memory usage (GPU)
Frames
Clear color
Clear alpha
Status
Local clipping enabled
Capabilities
WebGL 2
Max texture units
Max texture size
Precision
Max fragment shader uniforms
Logarithmic depth buffer
Max shader attributes
Check shader errors
EXT_clip_control
EXT_color_buffer_float
EXT_color_buffer_half_float
EXT_conservative_depth
EXT_depth_clamp
EXT_float_blend
EXT_polygon_offset_clamp
EXT_texture_compression_bptc
EXT_texture_compression_rgtc
EXT_texture_filter_anisotropic
EXT_texture_mirror_clamp_to_edge
EXT_texture_norm16
NV_shader_noperspective_interpolation
OES_draw_buffers_indexed
OES_sample_variables
OES_shader_multisample_interpolation
OES_texture_float_linear
OVR_multiview2
WEBGL_clip_cull_distance
WEBGL_compressed_texture_astc
WEBGL_compressed_texture_etc
WEBGL_compressed_texture_etc1
WEBGL_compressed_texture_s3tc
WEBGL_compressed_texture_s3tc_srgb
WEBGL_debug_renderer_info
WEBGL_debug_shaders
WEBGL_lose_context
WEBGL_multi_draw
WEBGL_polygon_mode
WEBGL_stencil_texturing
MSAA
EDL
EDL Radius
EDL Strength
Inpainting
Inpainting steps
Inpainting depth contrib.
Point cloud occlusion
Type
FOV
Automatic plane computation
Far plane
Near plane
Max far plane
Min near plane
Width (pixels)
Height (pixels)
x
y
z
x
y
z
color
Enable cache
Default TTL (seconds)
Capacity (MB)
Capacity (entries)
Entries
Memory usage (approx)
Pending requests
Memory tracker
Identifier
Memory usage (CPU)
Memory usage (GPU)
Status
Render order
Enable
Plane normal X
Plane normal Y
Plane normal Z
Distance
Helper size
Negate plane
Visible
Freeze updates
Opacity
Show volumes
Volume color
Data projection
Wireframe
Materials
Identifier
Memory usage (CPU)
Memory usage (GPU)
Status
Render order
Enable
Plane normal X
Plane normal Y
Plane normal Z
Distance
Helper size
Negate plane
Visible
Freeze updates
Opacity
Show volumes
Volume color
Data projection
Wireframe
Materials
Show helpers
Show hidden objects
Name filter
Hierarchy
Properties
isObject3D
uuid
name
type
matrixAutoUpdate
matrixWorldAutoUpdate
matrixWorldNeedsUpdate
visible
castShadow
receiveShadow
frustumCulled
renderOrder
x
y
z
x
y
z
Style
Colors
0%

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.42.4"
    },
    "devDependencies": {
        "vite": "^3.2.3"
    },
    "scripts": {
        "start": "vite",
        "build": "vite build"
    }
}