跳到主要內容

【2026 最新】God’s Eye View 教學:零 API Key 啟動 3D 情報台,接上台北 YouBike 與 AI 語音

最後更新: ·
God’s Eye View 教學:零 Key 台北情報台與安全 AI 語音

這篇 God’s Eye View 教學先從一個錯覺開始:當畫面同時出現飛機、衛星、地震、公開攝影機與軍事電影式 HUD,很容易讓人直覺認為這就是「即時情報」。但真正難的不是把點畫上地球,而是讓讀者知道每個點從哪裡來、晚了多久、哪些是推算、哪些只是模擬。

這篇 God’s Eye View 教學專為第一次碰 3D 地理資料的讀者寫。我會先帶你用零 API Key 啟動可用基線,再接上台北市 YouBike 2.0 公開站況,做出有來源、時間與錯誤狀態的台灣資料層;最後才加入 OpenAI 語音,並把金鑰、花費、資料外流與無 Key 降級一起納入驗收。

本文實作固定在官方 repo 的 d15cc383 commit(2026 年 9 月 21 日驗證);同日 main 已往前更新,因此下方指令會明確 checkout,而不是假設 clone 下來仍是同一版。專案變動很快,先學會「釘住版本+驗收狀態」,比背下今天有幾個 layer 更重要。

先說結論:God’s Eye View 教學真正要做的是「可信」

可信 3D 情報台 = 地圖殼 × 資料來源 × 時間戳 × 狀態標籤 × 失效降級。

  • 零 Key 可以先做:啟動 Cesium 3D 地球、Esri 衛星影像與 Re:Earth keyless terrain,並使用飛機、軍機、衛星、地震、部分公開攝影機、廣播、交通模擬等免 Key 路徑。
  • 零 Key 不等於:Google Photorealistic 3D 城市、所有 feed 都免費、每個點都是直接觀測,也不等於可拿來導航或救災。
  • 台灣 layer 選台北 YouBike:官方 JSON 不需 Key、目前允許 CORS、資料結構適合新手,而且更新頻率標示為每分鐘;範圍是台北市,不能寫成全台資料。
  • AI 最後才開:沒有 OpenAI Key 時,地圖仍可用、語音維持關閉、HUD 走確定性摘要。這正是我們要保留的降級路徑。

God’s Eye View 是什麼?先拆掉「軍事級即時」錯覺

God’s Eye View 是一個開源、local-first 的公共資料融合介面:它把不同來源的事件、移動資產與基礎設施放進 Cesium 地球,再加上追蹤、攝影機、場景與 AI 工具。官方 README自己把它定位為探索與學習用的快速原型,不是 hardened production service。

關鍵是:畫面混合了不同真實度。飛機與大眾運輸會做延遲播放、內插或短期推算;衛星位置來自軌道元素經 SGP4 計算;keyless 交通車點是在 OSM 道路上的模擬;攝影機位置可來自提供者,但朝向常是估計;火箭路徑則可能是重建軌跡。官方 DATA_SOURCES.md把這些差異逐層列出。

介面上的 TOP SECRET、任務代號與軌道/pass 編號也屬視覺語言。原始碼會產生部分美學數值;它們不是機密權限或真實衛星遙測。最公允的定位是:這是一個完成度很高的資料融合介面實驗,適合學習視覺化與互動,不該用視覺氣勢替資料背書。

步驟一:零 API Key 啟動 God’s Eye View

目前正式 release 是 v0.1.1;本文則釘住較新的 d15cc383 snapshot。先確認 Node 版本,再 checkout 完整 commit。官方 package.json要求 Node 24.14 以上的 24.x,或 26.x;不要用已 EOL 的 Node 25。

git clone https://github.com/bilawalsidhu/gods-eye-view.git
cd gods-eye-view
git checkout --detach d15cc383c3afc1126174814b003a7c4a7e62e731
git rev-parse HEAD
node --version
npm ci
npm run doctor
npm run build
npm run dev

瀏覽器開啟 http://localhost:4173。若要驗證真正的無 Key 狀態,除了不建立 .env,還要確認 shell、其他 dotenv 檔與 macOS Keychain 都沒有相關憑證,再執行 npm run doctor。它應把地圖列為 Esri World Imagery + keyless terrain,把語音、船舶與火災等需要憑證的能力明確標成關閉。這一步的標準不是「所有按鈕都亮」,而是沒 Key 時也能誠實地工作

God’s Eye View 在沒有 API Key 的本機環境中顯示 Esri 衛星底圖、HUD 與首次啟動畫面
AlphaLab 於 2026 年 9 月 21 日用 d15cc383 clean clone 啟動的無 Key 畫面;底圖可用,但語音顯示 OFF,POWER UP 仍列出等待設定的供應商。

你現在看到的是 3D 地球與地形,不是 Google Photorealistic 3D 城市網格。後者需要符合資格的 Cesium ion token,或直接計費的 Google Maps Key。先用 keyless baseline 完成資料與失效驗收,能把「底圖升級」和「資料 layer 是否正確」分成兩題。

步驟二:先做 data provenance 面板,再加資料

Data provenance 就是「這筆資料的身分證」。同一個漂亮 marker,可能是剛抓到的站況、延遲 30 秒的回報、模型外推的位置,或純粹為了讓畫面動起來的模擬。只寫一個綠色 LIVE,等於把四種語意壓成一種。

3D 情報台將觀測快照、延遲呈現、模型推算與視覺模擬分開標示的資料真偽面板
不要問「是不是即時」這個二選一問題;要問它是哪一種狀態、來源時間是什麼、失敗後保留什麼。

每個 layer 至少保存以下物件。sourceTime是提供者說資料何時產生,fetchedAt是你的程式何時拿到;兩者不能混成同一欄。fallback也要是明文狀態,不要偷偷把舊資料當新資料。

const provenance = {
  source: "Taipei DOT / YouBike 2.0",
  mode: "OBSERVED_SNAPSHOT",
  sourceTime: null,
  fetchedAt: null,
  status: "LOADING",   // LIVE | STALE | DEGRADED | UNAVAILABLE
  fallback: "KEEP_LAST_GOOD",
  error: null,
};
  1. 觀測快照:顯示提供者時間與取得時間;站況不是預約結果,也不保證你抵達時仍有車。
  2. 延遲呈現:把延遲秒數寫出來,不用「平滑動畫」掩蓋資料晚到。
  3. 模型推算:標出模型名稱或推算方法;模型輸出不冒充直接感測。
  4. 視覺模擬:直接寫 SIMULATED;God’s Eye View 的 keyless traffic 就屬這一類。
  5. 失效降級:保留 last-good 時同步標成 STALE,不能一邊顯示舊點、一邊繼續亮綠燈。

步驟三:新增台北 YouBike 2.0 公開資料 layer

這篇選用臺北市政府交通局「YouBike2.0 臺北市公共自行車即時資訊」。官方目錄標示每分鐘更新;資料依政府資料開放授權條款第 1 版使用。撰稿當下的 JSON endpoint 回傳 HTTP 200、Access-Control-Allow-Origin: *,不需 Key 或 Cookie,因此可從 Vite 前端直接讀取。

2026 年 9 月 21 日 07:19(台北時間)抓取的本文快照約 944 kB、1,803 筆;feed 內場站來源時間原字串介於 07:17:02 至 07:17:18,官方資料未附時區偏移。這只是當次快照,不是固定站數。實際 payload 還有四個很容易踩的坑:Quantity的 Q 是大寫;act是字串;時間字串沒有時區偏移;而且可借車+可還空位不應由程式假設永遠等於容量。

官方目錄只寫經緯度欄位,未明列 CRS;本文依目前十進位座標的順序,把 longitudelatitude交給 Cesium,實測落點在台北。當前 feed 觀察到 act === "1"對應營運中、"0"對應停用,但官方欄位說明沒有公布正式 enum,所以把這個 mapping 集中在一處,未來可單獨更換。

把下面的完整檔案存成 src/experiments/taipeiYouBikeLayer.js。這版採嚴格 snapshot:null、空字串、非有限數、越界座標或任一壞列都會拒收整批;不會把 Number(null)誤當成合法的 0,也不會靜默丟掉壞列後假裝資料完整。

import * as Cesium from 'cesium';

const SOURCE_URL =
  'https://tcgbusfs.blob.core.windows.net/dotapp/youbike/v2/youbike_immediate.json';
const REFRESH_MS = 60_000;
const STALE_MS = REFRESH_MS * 2;

const toFinite = (value, field, rowIndex) => {
  if (
    (typeof value !== 'number' && typeof value !== 'string') ||
    (typeof value === 'string' && value.trim() === '')
  ) {
    throw new TypeError(`row ${rowIndex}: ${field} is missing`);
  }
  const number = Number(value);
  if (!Number.isFinite(number)) {
    throw new TypeError(`row ${rowIndex}: ${field} is not finite`);
  }
  return number;
};

function normalizeStation(raw, rowIndex) {
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
    throw new TypeError(`row ${rowIndex}: expected an object`);
  }
  const id = String(raw.sno ?? '').trim();
  if (!id) throw new TypeError(`row ${rowIndex}: sno is missing`);
  const name = String(raw.sna ?? '').trim();
  if (!name) throw new TypeError(`row ${rowIndex}: sna is missing`);
  if (raw.act !== '0' && raw.act !== '1') {
    throw new TypeError(`row ${rowIndex}: act is unknown`);
  }
  const updatedAt = String(raw.infoTime || raw.mday || '').trim();
  const timeParts = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(
    updatedAt,
  );
  if (!timeParts) {
    throw new TypeError(`row ${rowIndex}: source time is invalid`);
  }
  const [year, month, day, hour, minute, second] = timeParts
    .slice(1)
    .map(Number);
  // Date.UTC is used only to reject impossible calendar fields; the feed still
  // has no declared timezone and updatedAt remains the unmodified raw string.
  const calendarCheck = new Date(
    Date.UTC(year, month - 1, day, hour, minute, second),
  );
  if (
    calendarCheck.getUTCFullYear() !== year ||
    calendarCheck.getUTCMonth() !== month - 1 ||
    calendarCheck.getUTCDate() !== day ||
    calendarCheck.getUTCHours() !== hour ||
    calendarCheck.getUTCMinutes() !== minute ||
    calendarCheck.getUTCSeconds() !== second
  ) {
    throw new TypeError(`row ${rowIndex}: source time is invalid`);
  }

  const lat = toFinite(raw.latitude, 'latitude', rowIndex);
  const lon = toFinite(raw.longitude, 'longitude', rowIndex);
  const available = toFinite(
    raw.available_rent_bikes,
    'available_rent_bikes',
    rowIndex,
  );
  const returns = toFinite(
    raw.available_return_bikes,
    'available_return_bikes',
    rowIndex,
  );
  const capacity = toFinite(raw.Quantity, 'Quantity', rowIndex);
  if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
    throw new RangeError(`row ${rowIndex}: coordinates are out of range`);
  }
  if (available < 0 || returns < 0 || capacity < 0) {
    throw new RangeError(`row ${rowIndex}: bike counts cannot be negative`);
  }

  return {
    id,
    name: name.replace(/^YouBike2\.0_/, ''),
    lat,
    lon,
    available,
    returns,
    capacity,
    // Current-feed observation; the official catalog does not publish the enum.
    active: raw.act === '1',
    updatedAt,
  };
}

function normalizeSnapshot(payload) {
  if (!Array.isArray(payload) || payload.length === 0) {
    throw new TypeError('YouBike response must be a non-empty array');
  }
  return payload.map(normalizeStation); // strict: one bad row rejects the snapshot
}

const barHeight = (station) =>
  station.active ? 20 + Math.sqrt(station.available) * 30 : 8;

const barColor = (station) => {
  if (!station.active) return Cesium.Color.GRAY;
  if (station.available === 0) return Cesium.Color.fromCssColorString('#DC322F');
  if (station.available < 4)
    return Cesium.Color.fromCssColorString('#F5A623');
  return Cesium.Color.fromCssColorString('#2EA043');
};

function buildDataSource(stations) {
  const source = new Cesium.CustomDataSource('taipei-youbike-next');
  for (const station of stations) {
    const height = barHeight(station);
    source.entities.add(
      new Cesium.Entity({
        id: `taipei-youbike:${station.id}`,
        name: station.name,
        position: Cesium.Cartesian3.fromDegrees(
          station.lon,
          station.lat,
          height / 2,
        ),
        cylinder: {
          length: height,
          topRadius: 8,
          bottomRadius: 8,
          heightReference: Cesium.HeightReference.RELATIVE_TO_GROUND,
          material: barColor(station).withAlpha(0.82),
        },
        properties: station,
      }),
    );
  }
  return source;
}

export function createTaipeiYouBikeLayer({
  fetchImpl = (...args) => fetch(...args),
  now = () => Date.now(),
} = {}) {
  let viewer = null;
  let activeSource = null;
  let request = null;
  let enabled = false;
  let stats = {
    count: 0,
    fetchedAt: null,
    sourceTime: null,
    lastSuccessMs: null,
    error: null,
  };

  const publicStats = () => {
    const ageMs = stats.lastSuccessMs === null ? null : now() - stats.lastSuccessMs;
    const status = stats.lastSuccessMs === null
      ? stats.error ? 'UNAVAILABLE' : 'LOADING'
      : ageMs > STALE_MS ? 'STALE'
        : stats.error ? 'DEGRADED' : 'LIVE';
    return { ...stats, ageMs, status };
  };

  return {
    id: 'taipei-youbike',
    name: 'Taipei YouBike',
    icon: '🚲',
    source: 'Taipei DOT · 60s',
    updateInterval: REFRESH_MS,

    init(nextViewer) {
      viewer = nextViewer;
    },

    enable() {
      enabled = true;
      if (activeSource) activeSource.show = true;
    },

    disable() {
      enabled = false;
      request?.abort();
      request = null;
      if (activeSource) activeSource.show = false;
    },

    async update() {
      if (!enabled || !viewer) return false;
      request?.abort();
      const current = new AbortController();
      request = current;
      let nextSource = null;

      try {
        const response = await fetchImpl(SOURCE_URL, {
          signal: current.signal,
          cache: 'no-cache',
        });
        if (!response.ok) throw new Error(`YouBike HTTP ${response.status}`);
        const stations = normalizeSnapshot(await response.json());
        nextSource = buildDataSource(stations);

        if (current.signal.aborted || request !== current || !enabled) return false;
        await viewer.dataSources.add(nextSource);
        if (current.signal.aborted || request !== current || !enabled) {
          viewer.dataSources.remove(nextSource, true);
          return false;
        }

        const previous = activeSource;
        nextSource.show = true;
        activeSource = nextSource;
        if (previous) viewer.dataSources.remove(previous, true);

        const successMs = now();
        stats = {
          count: stations.length,
          fetchedAt: new Date(successMs).toISOString(),
          sourceTime: stations.reduce(
            (latest, station) =>
              station.updatedAt > latest ? station.updatedAt : latest,
            '',
          ) || null,
          lastSuccessMs: successMs,
          error: null,
        };
        viewer.scene.requestRender();
        return true;
      } catch (error) {
        if (nextSource && nextSource !== activeSource) {
          viewer?.dataSources.remove(nextSource, true);
        }
        if (current.signal.aborted || request !== current || !enabled) return false;
        stats = { ...stats, error: error?.message || 'YouBike unavailable' };
        return false; // keep activeSource as last-good
      } finally {
        if (request === current) request = null;
      }
    },

    destroy() {
      enabled = false;
      request?.abort();
      request = null;
      if (activeSource && viewer) viewer.dataSources.remove(activeSource, true);
      activeSource = null;
      viewer = null;
    },

    getStats: publicStats,
  };
}

這份 module 會由 God’s Eye View 的 manager 每 60 秒呼叫 update()。它先離線建立新的 CustomDataSource,完成後才交換;抓取或 schema 失敗時,舊 source 不會被清掉。getStats()會依最後成功時間回傳 LIVEDEGRADEDSTALEUNAVAILABLE。柱高仍只是「可借車數」的 SIMULATED_VISUAL_ENCODING,不是地理高度;RELATIVE_TO_GROUND只負責把柱底跟著地形。

接著在 src/main.js加入 import,並把原本的 application.start()改成下面的版本。這個小面板會直接顯示 source time、fetch time、狀態、fallback 與錯誤,讓資料失效不只存在 console 裡。

import { createTaipeiYouBikeLayer } from "./experiments/taipeiYouBikeLayer.js";

function mountProvenance(layer) {
  const panel = document.createElement("pre");
  panel.setAttribute("aria-label", "Taipei YouBike data provenance");
  panel.style.cssText =
    "position:fixed;right:16px;bottom:16px;z-index:10000;width:320px;" +
    "padding:12px;border:1px solid #22d3ee;border-radius:8px;" +
    "background:rgba(3,15,24,.92);color:#d8fbff;font:12px/1.45 monospace";
  document.body.append(panel);

  const render = () => {
    const s = layer.getStats();
    panel.textContent = [
      "TAIPEI YOUBIKE · OBSERVED_SNAPSHOT",
      `status: ${s.status}`,
      "source: Taipei DOT / YouBike 2.0",
      `sourceTime (feed raw; no offset): ${s.sourceTime ?? "—"}`,
      `fetchedAt (UTC): ${s.fetchedAt ?? "—"}`,
      `count: ${s.count}`,
      "fallback: KEEP_LAST_GOOD",
      `error: ${s.error ?? "—"}`,
    ].join("\n");
  };
  render();
  const timer = setInterval(render, 1000);
  return () => { clearInterval(timer); panel.remove(); };
}

application
  .start()
  .then(async () => {
    if (!import.meta.env.DEV) return;
    const layer = createTaipeiYouBikeLayer();
    const { dataManager } = window.__godsEyeView;
    window.__gevQaRegisterLayer(dataManager, layer);
    window.__gevRemoveYouBikePanel = mountProvenance(layer);
    await dataManager.setEnabled(layer.id, true, { origin: "programmatic" });
  })
  .catch((error) => console.error("God's Eye View initialization failed:", error));

AlphaLab 用有效 payload 跑過更新,再把 latitude改成 null:第一次成功為 LIVE,壞 snapshot 被拒收且 last-good source 仍在,當下為 DEGRADED,超過 120 秒轉為 STALE;若第一次載入就失敗則是 UNAVAILABLE。修改後的 production build 也已通過。

God’s Eye View 顯示台北市 YouBike 2.0 站點的 3D 彩色柱狀資料 layer
本文用釘選的 d15cc383開發期 QA seam 掛入自製 layer,成功載入當次 1,803 筆站點;柱高是可借車數的視覺編碼,不是真實地形高度。

釘選版本在開發模式提供 window.__gevQaRegisterLayer,適合本機實驗;這條 QA seam 不會自動成為正式 UI。要做 production patch,至少還要把 source 注入 catalog、加入 LAYER_STATE_REGISTRY與 Layer Panel 排序,同步更新 DATA_SOURCES.mddataCredits.js,補 source-provider wiring 與測試。這些步驟確保分享連結、生命週期、顯名與錯誤行為不會漏掉。

步驟四:做一次「拔線驗收」

在 Chrome DevTools 的 Network request blocking 暫時擋住 YouBike endpoint,或把 URL 改成不存在的測試路徑。驗收不是看 console 有沒有紅字,而是逐項確認讀者看見的狀態。

  • 正常:站點出現;面板同時顯示來源時間與本機取得時間。
  • 一次失敗:上一版仍在,但狀態轉為 DEGRADED,並顯示最後成功時間。
  • 超過兩個更新週期:轉為 STALE;不再使用綠色 live 標籤。
  • 首次啟動就失敗:顯示 UNAVAILABLE,不建立假資料填空。
  • 重新連線:只有完整新 snapshot 通過檢查後才原子替換,錯誤狀態清除。

這也是你評估任何新 layer 的共同方法。如果想把這種「模型做事、Harness 驗收」的觀念補齊,可先讀 AI Agent Harness 是什麼AI Agent Harness 實作;資料工具和 Agent 工具雖然表面不同,可靠性都來自明確狀態與外部驗收。

步驟五:安全加入 AI 語音與 HUD

等無 Key 基線與拔線驗收都過了,再為 GEV 建獨立 OpenAI project。釘選版本可讓你在瀏覽器的 POWER UP → Provider Settings 貼入 Key;這表示長效 Key 會短暫存在 input,經 localhost POST 到本機 server,之後寫進 gitignored 的 .env或 Pinokio ENVIRONMENT。兩者仍是 plaintext,不是加密 secret store;macOS 可改用 Keychain 搭配 ./scripts/dev-fresh.sh

正常 Realtime 連線不會把 server 儲存的長效 OPENAI_API_KEY回傳給前端:server 向 OpenAI 鑄造短效 client secret,瀏覽器再用它建立 WebRTC。OpenAI Realtime 官方指南把它列為標準瀏覽器流程之一。短效憑證仍不是一次性憑證;在有效期內,遭入侵或不受信任的瀏覽器環境、惡意擴充套件或 XSS 仍可能取用。

God’s Eye View AI 語音從瀏覽器經本機伺服器取得短效憑證再連線 OpenAI 的安全資料路徑
正常 Realtime 連線只把短效 client secret 交給瀏覽器;但設定頁輸入 Key 的瞬間、送出的音訊與場景脈絡,仍是必須另外評估的風險。

截至 2026 年 9 月 21 日,repo 的 standard/mini 預設分別是 gpt-realtime-2gpt-realtime-2.1-mini。OpenAI 當日官方價中,兩者 audio input/output 每百萬 token 分別是 US$32/64 與 US$10/20。HUD 另用 gpt-5-nano,文字 input/cached input/output 分別為 US$0.05/0.005/0.40。這些都不是固定「每分鐘價」:上下文、工具、影像、快取與說話比例都會改變成本;上線前要重查模型頁。

repo 內建的 US$2 warning 與 US$5 hard cap,是收到 usage 後估算、只會關掉這一次本機語音 session的保護;它不是 OpenAI 帳單上限,也不涵蓋 HUD 或同 project 的其他程式。較強的供應商側 backstop 是 project hard monthly spend limit、spend alert、模型權限與 rate limit;alert 只通知、不停流量,hard limit enforcement 也非即時,帳面支出可能略高於設定門檻。若帳號與 Key 類型支援 roles/scopes,再縮小權限。想先理解長對話成本,可延伸讀 AI token 節省方法

AI 語音的五項驗收

  1. 範圍:維持 localhost;不要把 Vite dev server 直接公開到網路或不受信任 LAN。
  2. Key:用獨立 project,allowlist 要包含真正用到的 Realtime 與 HUD 模型,測完可單獨 revoke;不要共用 production Key。
  3. 花費:同時設供應商 hard limit 與 app rate limit;但 GEV_RATELIMIT_OPENAI_PER_MIN未設定時 limiter 是 no-op,而且只管 mint secret 與 HUD,不能限制已建立 Realtime session 內的每次語音 turn。
  4. 資料:麥克風、座標、地點文字、工具結果,以及特定情況下的 Cesium canvas 截圖會離開裝置;開啟公開攝影機前先考慮畫面中的人、車與車牌。
  5. 降級:移除 Key 後,token route 應回未設定、mic 顯示不可用;地圖與確定性 HUD 仍要正常工作。

還有兩層保存問題。第一層在本機:釘選版本會把 Realtime event 追加到 .gev-logs/realtime-conversations.jsonl。預設 voice client 會嘗試遮蔽 secret-like 欄位、Bearer 與 image data URL,但 server logger 不做第二次 sanitizer;文字、transcript、工具參數與場景脈絡仍可能是 plaintext,而且 repo 沒有內建總量輪替或自動刪除。

第二層在供應商。依 2026 年 9 月 21 日的 OpenAI API data controls,API 資料預設不拿來訓練,但一般專案的 abuse-monitoring logs 預設最多可保留 30 天;Realtime 沒有 application-state retention。釘選版本的 HUD 則呼叫 Responses API 且未送 store:false,依當日預設,其 response data 至少保存 30 天。即使補上 store:false,也不代表一般專案自動取得 Zero Data Retention。

God’s Eye View 教學常見問題

1. God’s Eye View 完全免費嗎?

不是「所有能力永久零成本」。程式碼採 MIT,keyless baseline 可免帳號啟動;第三方資料、Google/Cesium、OpenAI、AISStream、FIRMS 與 TomTom 各有自己的授權、資格、配額或費用。MIT 也不會替第三方資料重新授權。

2. 零 API Key 能看到 Photorealistic 3D 城市嗎?

不能把 keyless 模式寫成 Google Photorealistic 3D。無 Key 預設是 Esri imagery + Re:Earth terrain;影像失敗時可退到 OSM,地形再失敗則退到平坦 ellipsoid。Photorealistic city mesh 需要另外的 Cesium ion 或 Google 憑證。

3. 畫面上的資料全部都是即時真實位置嗎?

不是。其中同時存在觀測快照、延遲播放、內插/推算、模擬交通、估計攝影機姿態與重建火箭軌跡。每個 layer 都要分開標籤,不能用一個 LIVE概括。

4. 為什麼不用台灣地震或災防示警做第一個 layer?

因為第一篇要先把資料工程做對,而且故障不該傷害公共安全。氣象署顯著有感地震正式 API 需要會員授權碼;示警資料還牽涉更新、取消、有效期與適用區域。先用 YouBike 練 snapshot、時間與失效,再另做完整示警生命週期,風險較低。

5. 這個 YouBike layer 涵蓋全台灣嗎?

沒有,本文只使用台北市資料。資料來源、地理範圍與提供機關都要跟著 layer 顯示;不能因為專案在台灣使用,就把台北 endpoint 改名成「全台 YouBike」。

6. OpenAI API Key 會送到瀏覽器嗎?

要分「設定」與「正常連線」兩段看。若你在 Provider Settings 貼入 Key,長效 Key 會短暫進入 browser,再經 localhost POST 寫入本機 plaintext 設定;正常 Realtime 連線不會把已儲存的長效 Key 回傳,只把 client secret 給 browser。釘選版本未自訂到期時間,採官方當日預設約 600 秒;到期前可建立多個 session,已建立的 session 也不會因 secret 到期立刻中止。

7. 內建 US$5 hard cap 代表最多只花 5 美元嗎?

不是。它只在收到已完成 response 的 usage 後累計;跨越門檻的 response 已經發生成本,因此停止時可能超過 US$5。guard 可以關閉,中途結束的 response 也可能缺 usage;它不含其他 session 或 endpoint,更不是保證的帳單上界。另設 project hard limit,並記得 alert 本身只通知。

8. 可以直接把這個 Vite 專案公開部署嗎?

不建議照原樣公開。官方安全文件把它定位為 local-first,預設綁 localhost。若要遠端服務,至少需要經審查的驗證 proxy、供應商配額、Key 限制、資料與 log 保存政策及逐 endpoint 濫用測試;若服務能識別個別使用者,再依 OpenAI 建議,把固定的 Realtime safety identifier 改成穩定且保護隱私的 per-user 值。

給新手的 6 個重點

  1. 先固定 commit、跑 doctor與 build,再討論畫面。
  2. 把「3D 地球」與「Photorealistic 3D 城市」分開。
  3. 任何 marker 都要帶來源、來源時間、取得時間與狀態。
  4. 台北 YouBike 是好上手的公開資料,不代表全台覆蓋或保證可租。
  5. 更新失敗時保留 last-good,但一定要同步標成 degraded/stale。
  6. AI 語音是可拔除的增強層;移除 Key 後,核心作品仍要可展示、可操作。

如果你接下來想把這個 prototype 做成更完整的互動作品,可以參考 Claude Code 動畫網站教學補前端迭代方法,再用 AI 網頁設計工作流整理視覺層級;更多入門路線集中在 AlphaLab AI 專區AlphaLab 課程

接著閱讀

左右滑動查看更多推薦

結語:先讓每一個點說清楚自己是誰

回到開頭那條公式:可信 3D 情報台 = 地圖殼 × 資料來源 × 時間戳 × 狀態標籤 × 失效降級。God’s Eye View 最值得學的,不是把畫面做得像情報中心,而是用一個吸睛介面逼自己面對資料融合的真問題。

今天先完成一個最小作品:零 Key 啟動、接上台北 YouBike、在 Network 面板故意讓 endpoint 失敗,確認 last-good 與 STALE真的出現。這四步都過了,再加 AI 語音。你做出的就不只是會動的地球,而是一個別人能理解、能質疑,也能安全拔掉昂貴功能的資料作品。

ALPHALAB 社群

有問題?來 Telegram 聊

和 Terry、編輯、其他網友一起討論這篇文章。提問、分享觀點,回覆更即時。

加入 Telegram 討論

📩 訂閱 AlphaLab 電子報

每週最多三封:一封 Weekly 週報與最多兩封關鍵 Alpha Signal。

我們不會 spam,隨時可退訂。