OnlyMapJS Latest release
GitHub Docs

← Gallery / 3D, terrain & lighting

Place a 3D model

Place a glTF or GLB model at map coordinates with ScenegraphLayer. One asset, one instance per row, no three.js.

Open full screen ↗ View source ↗
<!DOCTYPE html>
<!--
  ScenegraphLayer puts a glTF/GLB model on the map at a longitude and
  latitude. Reach for it when a place needs a SHAPE rather than a dot —
  a building, a turbine, a crane, a vehicle. deck.gl fetches and parses the
  asset itself, so there is no three.js, no loader wiring and no scene setup:
  one attribute names the file, and each row of data becomes one instance of
  it. Domain formats stay upstream — convert IFC or CAD to GLB on your
  server and the manifest renders the result untouched.

  The map: fourteen buildings south of Market in San Francisco, each the same
  1.4 KB box stretched onto a real footprint from the basemap and turned to
  that footprint's angle, so the boxes sit on the plots instead of across
  them. Colour is the land use and the low afternoon sun rakes across them.
  Drag to orbit, or hover a building for its record.
-->
<html>
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>OnlyMapJS — Place a 3D model</title>
  <link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap/dist/onlymapjs.css" />
  <script type="module" src="https://unpkg.com/@nika-js/onlymap/dist/onlymap.standalone.js"></script>
  <style>
    :root { color-scheme: dark; }
    html, body { margin: 0; height: 100%; font-family: system-ui, sans-serif; }
    om-map {
      display: block; height: 100vh;
      --om-widget-bg: #14161c;
      --om-widget-fg: #f8fafc;
      --om-widget-muted: #8b93a7;
      --om-widget-border: #2c3040;
      --om-widget-hover-bg: #22262f;
    }
  </style>
</head>
<body>

  <!-- lighting="daylight" gives the models a real sun to catch. lighting-sun-date
       places that sun from a date and time, so the light rakes in low and the
       faces separate instead of flattening into one silhouette. Raise
       lighting-ambient on a dark basemap — it lifts the faces the sun misses,
       which would otherwise sink into the map behind them. Any lighting-*
       attribute overrides just that one number of the preset. -->
  <om-map center="[-122.3985, 37.7902]" zoom="15.9" pitch="62" bearing="28"
          basemap="dark-matter"
          lighting="daylight" lighting-sun-date="2026-02-14T01:10:00Z"
          lighting-ambient="2.4" lighting-sun="2.1">

    <!-- scenegraph names ONE asset; every row instances it again, which is the
         whole point of the layer — a second model means a second layer.

         glTF models are Y-up and the map is Z-up, so the roll of 90 in
         get-orientation="[pitch, yaw, roll]" stands the box upright. Yaw is
         the heading field, so each tower faces its own street.

         get-scale is model space [x, y, z] — for an upright Y-up box that
         reads as width, height, depth in metres. lighting="pbr" shades the
         model by its own glTF materials rather than a flat fill.

         get-color tints each instance. A chain of equality tests gives one
         flat colour per land use — the right shape when the value is a label
         rather than a quantity, and four distinct hues stay separable on a
         dark basemap where a single ramp would blur together. -->
    <om-layer id="towers" type="ScenegraphLayer"
              scenegraph="../../data/box.glb"
              label="Towers"
              get-position="[$lon, $lat]"
              get-orientation="[0, $heading, 90]"
              get-scale="[$width, $height, $depth]"
              get-color="$use == 'Office' ? '#7dd3fc' :
                         $use == 'Retail' ? '#fdba74' :
                         $use == 'Residential' ? '#86efac' : '#fb7185'"
              lighting="pbr" pickable>
      <!-- Inline JSON instead of a data attribute — no fetch, and the rows sit
           next to the accessors that read them. Point the `data` attribute at
           a URL instead the moment the list outgrows the page. Each row here
           carries only what an accessor above asks for: a coordinate, a
           heading, and three dimensions. -->
      <script type="application/json">
        [
          { "name": "Alpha Hall",     "use": "Office",      "lon": -122.39813, "lat": 37.78811, "heading": 44.8,  "width": 113, "depth": 49, "height": 55 },
          { "name": "Beta Market",    "use": "Retail",      "lon": -122.39491, "lat": 37.79105, "heading": 133.5, "width": 90,  "depth": 48, "height": 45 },
          { "name": "Gamma Court",    "use": "Residential", "lon": -122.40173, "lat": 37.78963, "heading": 9.2,   "width": 51,  "depth": 74, "height": 95 },
          { "name": "Delta Works",    "use": "Office",      "lon": -122.40187, "lat": 37.79137, "heading": 8.8,   "width": 77,  "depth": 43, "height": 70 },
          { "name": "Epsilon Library","use": "Civic",       "lon": -122.40131, "lat": 37.79084, "heading": 8.7,   "width": 76,  "depth": 39, "height": 60 },
          { "name": "Zeta Exchange",  "use": "Office",      "lon": -122.40046, "lat": 37.79259, "heading": 98.8,  "width": 37,  "depth": 76, "height": 120 },
          { "name": "Eta Residences", "use": "Residential", "lon": -122.39796, "lat": 37.79286, "heading": 36.7,  "width": 52,  "depth": 49, "height": 140 },
          { "name": "Theta Arcade",   "use": "Retail",      "lon": -122.39561, "lat": 37.79012, "heading": 44.8,  "width": 72,  "depth": 35, "height": 40 },
          { "name": "Iota Tower",     "use": "Office",      "lon": -122.39693, "lat": 37.78978, "heading": 130.0, "width": 49,  "depth": 49, "height": 165 },
          { "name": "Kappa Lofts",    "use": "Residential", "lon": -122.39670, "lat": 37.78849, "heading": 44.8,  "width": 58,  "depth": 42, "height": 85 },
          { "name": "Lambda Centre",  "use": "Office",      "lon": -122.39916, "lat": 37.79051, "heading": 44.8,  "width": 52,  "depth": 44, "height": 150 },
          { "name": "Mu Clinic",      "use": "Civic",       "lon": -122.39582, "lat": 37.78917, "heading": 135.2, "width": 41,  "depth": 47, "height": 50 },
          { "name": "Nu Parade",      "use": "Retail",      "lon": -122.39815, "lat": 37.79120, "heading": 134.9, "width": 55,  "depth": 34, "height": 35 },
          { "name": "Xi Apartments",  "use": "Residential", "lon": -122.39855, "lat": 37.78853, "heading": 135.2, "width": 43,  "depth": 40, "height": 110 }
        ]
      </script>
    </om-layer>

    <!-- Models pick like any other layer, so a hover behavior needs nothing
         special — the picked row fills the template. Filters, widgets and
         story steps all reach a ScenegraphLayer the same way.

         To fly your own asset in, point scenegraph= at your GLB and check two
         things: that its origin sits at the base, so get-position anchors the
         ground point rather than the middle, and that it is unit-sized, so
         get-scale reads in metres. -->
    <om-behavior on="hover" layer="towers" action="show-tooltip"
                 template="#tower-tooltip"></om-behavior>

    <om-widget type="zoom-controls" position="bottom-right"></om-widget>

    <!-- Write the key by hand when the classes carry words a reader needs
         named — the colours here are a land-use palette, not a scale. -->
    <om-widget position="top-right">
      <style>
        .key { background: var(--om-widget-bg); color: var(--om-widget-fg);
               border: 1px solid var(--om-widget-border); border-radius: 10px;
               padding: 10px 13px; font-size: 11.5px; min-width: 104px;
               box-shadow: 0 4px 18px rgba(0,0,0,.45); }
        .key h4 { margin: 0 0 7px; font-size: 10px; letter-spacing: .07em;
                  text-transform: uppercase; color: var(--om-widget-muted); }
        .key .row { display: flex; align-items: center; gap: 8px; margin: 4px 0; }
        .key .sw { width: 11px; height: 11px; border-radius: 2px; flex: none; }
      </style>
      <div class="key">
        <h4>Land use</h4>
        <div class="row"><span class="sw" style="background:#7dd3fc"></span>Office</div>
        <div class="row"><span class="sw" style="background:#fdba74"></span>Retail</div>
        <div class="row"><span class="sw" style="background:#86efac"></span>Residential</div>
        <div class="row"><span class="sw" style="background:#fb7185"></span>Civic</div>
      </div>
    </om-widget>

    <om-widget position="top-left">
      <style>
        .panel { background: var(--om-widget-bg); color: var(--om-widget-fg);
                 border: 1px solid var(--om-widget-border); border-radius: 10px;
                 padding: 12px 15px; max-width: 250px; font-size: 12.5px; line-height: 1.5;
                 box-shadow: 0 4px 18px rgba(0,0,0,.45); }
        .panel h1 { margin: 0 0 5px; font-size: 14px; }
        .panel em { color: var(--om-widget-muted); font-style: normal; }
      </style>
      <div class="panel">
        <h1>One model, fourteen instances</h1>
        Every building is the same GLB file, standing on a real footprint from
        the basemap and turned to that footprint's angle. Colour is the land
        use.
        <em>Drag to orbit. Hover a building for its record.</em>
      </div>
    </om-widget>

    <om-fallback>
      <p style="font: 15px system-ui, sans-serif; padding: 24px; max-width: 42ch">
        <strong>This map needs JavaScript.</strong><br />
        Open this file in a web browser such as Chrome, Safari, or Firefox.
      </p>
    </om-fallback>

  </om-map>

  <template id="tower-tooltip">
    <div style="background:#14161c; color:#f8fafc; border:1px solid #2c3040;
                padding:5px 9px; border-radius:5px; font:12px system-ui, sans-serif;">
      <b>{{name}}</b> — {{use}}, {{height}} m
    </div>
  </template>

</body>
</html>

The snippet above loads the latest release. The demo above it is pinned to v0.5.2, so it keeps working when a new version ships.