Skip to content

Emergency dispatcher ​

Type or speak what the caller says. The dispatcher picks the place; confidence in large type, the route from the base.

↗

Code ​

html
<!doctype html>
<!--
  dock sample app: emergency dispatcher. The caller describes the place as they say it ("Сансар 24", a shop
  name, a building code); the dispatcher types it or speaks it (the mic shows only where the browser has speech input).
  dock answers up to three places, never picks one: the dispatcher taps the one the caller confirms on the call.
  Then the card: confidence large, the entrance, the instructions, the gate photo slot, the route from the base.
  Nothing is sent until the dispatcher presses "Илгээх"; the call card JSON is printed for your dispatch system.
  Anything without a matched official address says "албан бус". A code with no door stays not found, never guessed.
  Serve sdk/js over http (npm run build, then e.g. `npx serve .`) and open /examples/emergency.html#key=dk_...
  The key needs door:read, geocode:read, route:read and tiles:read; a free key has them.
-->
<html lang="mn">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>dock: яаралтай дуудлага</title>
  <link href="https://dock.mn/sdk/v1/dock.css" rel="stylesheet">
  <script src="https://dock.mn/sdk/v1/dock.js"></script>
  <style>
    html, body { margin: 0; height: 100%; font: 15px system-ui, sans-serif; }
    body { display: grid; grid-template-columns: 440px 1fr; }
    aside { padding: 12px; overflow: auto; border-right: 1px solid #ddd; }
    #map { height: 100%; }
    textarea, input { width: 100%; box-sizing: border-box; padding: 6px; font: inherit; }
    textarea { height: 70px; }
    button { font: inherit; padding: 6px 12px; margin: 6px 6px 0 0; }
    ul { list-style: none; margin: 8px 0; padding: 0; }
    li button { width: 100%; text-align: left; margin: 2px 0; }
    #conf { font-size: 44px; font-weight: 700; padding: 6px 10px; border-radius: 8px; background: #dcfce7; }
    #conf.low { background: #fee2e2; color: #991b1b; }
    #photo { border: 1px dashed #aaa; padding: 10px; color: #555; margin: 8px 0; }
    pre { background: #f6f6f6; padding: 8px; overflow: auto; font-size: 12px; }
    [hidden] { display: none !important; }
  </style>
</head>
<body>
  <aside>
    <form id="ask">
      <label>Бааз (урт,өргөрөг)<input name="base" value="106.9177,47.9186"></label>
      <label>Дуудлага өгсөн хүний хэлсэн газар<textarea name="said"></textarea></label>
      <button>Хайх</button><button id="mic" type="button" hidden>Ярих</button>
    </form>
    <p id="msg"></p>
    <ul id="hits"></ul>
    <section id="card" hidden>
      <div id="conf"></div>
      <p id="where"></p>
      <p id="how"></p>
      <div id="photo"></div>
      <p id="trip"></p>
      <button id="send">Илгээх</button>
    </section>
    <pre id="out" hidden></pre>
  </aside>
  <div id="map"></div>
  <script type="module">
    const { maplibregl } = dock;
    const { Client, DockError, createMap, label } = dock;

    const params = new URLSearchParams(location.hash.slice(1));
    const client = new Client({ key: 'dk_...' });
    const map = createMap(client, { container: 'map', zoom: 13 });
    map.addControl(new maplibregl.NavigationControl());
    const $ = (id) => document.getElementById(id);
    const form = $('ask');
    let markers = [], picked = null, hits = [];

    // Speech input where the browser has it (Chrome sends the audio to its own service). Mongolian first.
    const Speech = window.SpeechRecognition ?? window.webkitSpeechRecognition;
    if (Speech) {
      $('mic').hidden = false;
      $('mic').onclick = () => {
        const rec = new Speech();
        rec.lang = 'mn-MN';
        rec.onresult = (e) => { form.said.value = e.results[0][0].transcript; form.requestSubmit(); };
        rec.start();
      };
    }

    // A building code (plus an optional flat) gives one door or none; words give up to three places, for a person to pick.
    async function find(text) {
      const [code, ...unit] = text.split(/\s+/);
      if (code.length === 5) {
        try {
          const door = await client.door(code, { unit: unit.join(' ') || undefined });
          return [{ end: door.code, point: door.point, text: label(door), confidence: door.confidence, unofficial: door.unofficial, door }];
        } catch (e) {
          if (e instanceof DockError && (e.code === 'not_found' || e.code === 'retired_code')) return [];
          if (!(e instanceof DockError && e.code === 'invalid_code')) throw e;
        }
      }
      const { results } = await client.geocode(text, { limit: 3 });
      return results.map((p) => ({ end: p.code ?? p.point, point: p.point, text: label(p), confidence: p.confidence, unofficial: p.unofficial, door: null }));
    }

    const base = () => form.base.value.split(',').map(Number);

    function draw(coords) {
      const data = { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } };
      if (map.getSource('run')) return map.getSource('run').setData(data);
      map.addSource('run', { type: 'geojson', data });
      map.addLayer({ id: 'run', type: 'line', source: 'run', paint: { 'line-color': '#b91c1c', 'line-width': 5 } });
    }

    form.addEventListener('submit', async (ev) => {
      ev.preventDefault();
      const said = form.said.value.trim();
      $('hits').replaceChildren();
      $('card').hidden = $('out').hidden = true;
      markers.forEach((m) => m.remove());
      markers = [];
      picked = null;
      if (!said) return;
      $('msg').textContent = 'Хайж байна...';
      try {
        hits = await find(said);
      } catch (e) {
        $('msg').textContent = e instanceof DockError ? `${e.code}: ${e.message}` : String(e);
        return;
      }
      if (!hits.length) { $('msg').textContent = `"${said}": олдсонгүй. Дуудлага өгсөн хүнээс дахин асуу.`; return; }
      $('msg').textContent = 'Дуудлага өгсөн хүнээр шалгуулж, нэгийг сонго:';
      for (const [i, h] of hits.entries()) {
        const b = document.createElement('button');
        b.type = 'button';
        b.textContent = `${i + 1}. ${h.text}`;
        b.onclick = () => pick(i);
        const li = Object.assign(document.createElement('li'), { id: `hit${i}` });
        li.append(b);
        $('hits').append(li);
        markers.push(new maplibregl.Marker({ color: '#6b7280' }).setLngLat(h.point).addTo(map));
      }
      const bounds = new maplibregl.LngLatBounds(hits[0].point, hits[0].point);
      hits.forEach((h) => bounds.extend(h.point));
      map.fitBounds(bounds, { padding: 80, maxZoom: 16 });
    });

    async function pick(i) {
      const h = (picked = hits[i]);
      $('out').hidden = true;
      $('card').hidden = false;
      const c = h.confidence;
      $('conf').textContent = c === null || c === undefined ? 'Итгэл: тодорхойгүй' : `Итгэл: ${c}/4`;
      $('conf').className = c === null || c === undefined || c < 2 ? 'low' : '';
      $('where').textContent = h.text;
      const d = h.door;
      $('how').textContent = d ? [d.entrance.letter && `${d.entrance.letter}-р хаалга`, d.unit?.floor != null && `${d.unit.floor} давхар`,
        d.instructions_mn].filter(Boolean).join('. ') : 'Хаалга тодорхойгүй: дуудлага өгсөн хүнээс хаалгаа асуу.';
      // dock serves a gate photo only to the org that holds a name for the door (doorPhoto), never by code to others.
      $('photo').textContent = 'Хаалганы зураг: алга. Ирэхдээ дуудлага өгсөн хүнээс хаалгаа тайлбарлуул.';
      $('trip').textContent = 'Зам тооцож байна...';
      markers.push(new maplibregl.Marker({ color: '#b91c1c' }).setLngLat(h.point).addTo(map));
      try {
        const r = await client.route(base(), h.end, { mode: 'driving' });
        if (picked !== h) return;
        draw(r.geometry.coordinates);
        map.fitBounds(new maplibregl.LngLatBounds(base(), base()).extend(h.point), { padding: 80, maxZoom: 16 });
        picked.route = { distance_m: Math.round(r.distance_m), duration_s: Math.round(r.duration_s) };
        $('trip').textContent = `Баазаас ${(r.distance_m / 1000).toFixed(1)} км, ойролцоогоор ${Math.round(r.duration_s / 60)} мин` +
          (r.to.gap_m > 30 ? `, дараа нь ${Math.round(r.to.gap_m)} м явган` : '') + '. Түгжрэл тооцоогүй.';
      } catch (e) {
        if (!(e instanceof DockError && e.code === 'no_route')) throw e;
        $('trip').textContent = 'Баазаас зам олдсонгүй.';
      }
    }

    // The human in the loop: the call card leaves only on the dispatcher's press, with what was picked and from how many.
    $('send').onclick = () => {
      if (!picked) return;
      const card = {
        said: form.said.value.trim(), picked_by: 'dispatcher', picked_of: hits.length, label_mn: picked.text,
        code: picked.door?.code ?? null, point: picked.point, confidence: picked.confidence ?? null,
        unofficial: picked.unofficial, route: picked.route ?? null,
      };
      $('out').textContent = JSON.stringify(card, null, 2);
      $('out').hidden = false;
    };
  </script>
</body>
</html>