diff --git a/packages/core/src/runtime/adapters/_readiness.ts b/packages/core/src/runtime/adapters/_readiness.ts new file mode 100644 index 000000000..60750175d --- /dev/null +++ b/packages/core/src/runtime/adapters/_readiness.ts @@ -0,0 +1,36 @@ +import type { RuntimeDeterministicAdapter } from "../types"; + +export function createReadinessAdapter(opts: { + name: string; + getInstances: () => T[]; + waitFor: (instance: T) => PromiseLike; +}): RuntimeDeterministicAdapter { + let pendingPromise: PromiseLike | null = null; + const settled = new WeakSet(); + + return { + name: opts.name, + discover: () => {}, + seek: () => {}, + pause: () => {}, + play: () => {}, + revert: () => {}, + getReadyPromise: () => { + const instances = opts.getInstances(); + if (instances.length === 0) return null; + const unsettled = instances.filter((i) => !settled.has(i)); + if (unsettled.length === 0) return null; + if (pendingPromise) return pendingPromise; + pendingPromise = Promise.allSettled( + unsettled.map((i) => + opts.waitFor(i).then(() => { + settled.add(i); + }), + ), + ).then(() => { + pendingPromise = null; + }); + return pendingPromise; + }, + }; +} diff --git a/packages/core/src/runtime/adapters/d3.test.ts b/packages/core/src/runtime/adapters/d3.test.ts new file mode 100644 index 000000000..49c0cf427 --- /dev/null +++ b/packages/core/src/runtime/adapters/d3.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createD3Adapter } from "./d3"; + +type D3TransitionLike = { + end: () => PromiseLike; +}; + +const d3Window = window as Window & { __hfD3?: D3TransitionLike[] }; + +function createMockTransition(opts?: { resolved?: boolean }): D3TransitionLike { + let resolver: (() => void) | null = null; + return { + end: vi.fn( + () => + new Promise((resolve) => { + if (opts?.resolved) { + resolve(); + } else { + resolver = resolve; + } + }), + ), + _resolve() { + resolver?.(); + }, + } as D3TransitionLike & { _resolve: () => void }; +} + +describe("d3 adapter", () => { + beforeEach(() => { + delete d3Window.__hfD3; + }); + + afterEach(() => { + delete d3Window.__hfD3; + }); + + it("has correct name", () => { + expect(createD3Adapter().name).toBe("d3"); + }); + + describe("getReadyPromise", () => { + it("returns null when no transitions registered", () => { + const adapter = createD3Adapter(); + expect(adapter.getReadyPromise!()).toBeNull(); + }); + + it("returns null when __hfD3 is empty", () => { + d3Window.__hfD3 = []; + const adapter = createD3Adapter(); + expect(adapter.getReadyPromise!()).toBeNull(); + }); + + it("resolves when transition ends", async () => { + const t = createMockTransition() as D3TransitionLike & { _resolve: () => void }; + d3Window.__hfD3 = [t]; + const adapter = createD3Adapter(); + const promise = adapter.getReadyPromise!(); + expect(promise).not.toBeNull(); + t._resolve(); + await promise; + }); + + it("resolves immediately for already-resolved transition", async () => { + const t = createMockTransition({ resolved: true }); + d3Window.__hfD3 = [t]; + const adapter = createD3Adapter(); + const promise = adapter.getReadyPromise!(); + expect(promise).not.toBeNull(); + await promise; + }); + + it("returns same promise on repeated calls (stable identity)", () => { + const t = createMockTransition(); + d3Window.__hfD3 = [t]; + const adapter = createD3Adapter(); + const p1 = adapter.getReadyPromise!(); + const p2 = adapter.getReadyPromise!(); + expect(p1).toBe(p2); + }); + + it("returns null after all transitions have settled", async () => { + const t = createMockTransition({ resolved: true }); + d3Window.__hfD3 = [t]; + const adapter = createD3Adapter(); + await adapter.getReadyPromise!(); + expect(adapter.getReadyPromise!()).toBeNull(); + }); + }); + + it("discover is a no-op", () => { + const adapter = createD3Adapter(); + expect(() => adapter.discover()).not.toThrow(); + }); + + it("seek is a no-op", () => { + const adapter = createD3Adapter(); + expect(() => adapter.seek({ time: 1 })).not.toThrow(); + }); +}); diff --git a/packages/core/src/runtime/adapters/d3.ts b/packages/core/src/runtime/adapters/d3.ts new file mode 100644 index 000000000..19a98e644 --- /dev/null +++ b/packages/core/src/runtime/adapters/d3.ts @@ -0,0 +1,17 @@ +import { createReadinessAdapter } from "./_readiness"; + +type D3TransitionLike = { + end: () => PromiseLike; +}; + +export function createD3Adapter() { + return createReadinessAdapter({ + name: "d3", + getInstances: () => { + if (typeof window === "undefined") return []; + const arr = (window as { __hfD3?: D3TransitionLike[] }).__hfD3; + return Array.isArray(arr) ? arr : []; + }, + waitFor: (t) => t.end(), + }); +} diff --git a/packages/core/src/runtime/adapters/google-maps.test.ts b/packages/core/src/runtime/adapters/google-maps.test.ts new file mode 100644 index 000000000..a3998b0a7 --- /dev/null +++ b/packages/core/src/runtime/adapters/google-maps.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createGoogleMapsAdapter } from "./google-maps"; + +type GoogleMapLike = { + addListener: (event: string, cb: () => void) => { remove: () => void }; +}; + +const mapWindow = window as Window & { __hfGoogleMaps?: GoogleMapLike[] }; + +function createMockMap(): GoogleMapLike & { _fire: (e: string) => void } { + const listeners: Record void; handle: { remove: () => void } }[]> = {}; + return { + addListener: vi.fn((event: string, cb: () => void) => { + const entry = { cb, handle: { remove: vi.fn() } }; + (listeners[event] ??= []).push(entry); + return entry.handle; + }), + _fire(event: string) { + for (const entry of listeners[event] ?? []) entry.cb(); + }, + }; +} + +describe("google-maps adapter", () => { + beforeEach(() => { + delete mapWindow.__hfGoogleMaps; + }); + + afterEach(() => { + delete mapWindow.__hfGoogleMaps; + }); + + it("has correct name", () => { + expect(createGoogleMapsAdapter().name).toBe("google-maps"); + }); + + describe("getReadyPromise", () => { + it("returns null when no maps registered", () => { + const adapter = createGoogleMapsAdapter(); + expect(adapter.getReadyPromise!()).toBeNull(); + }); + + it("returns null when __hfGoogleMaps is empty", () => { + mapWindow.__hfGoogleMaps = []; + const adapter = createGoogleMapsAdapter(); + expect(adapter.getReadyPromise!()).toBeNull(); + }); + + it("resolves when map fires tilesloaded", async () => { + const map = createMockMap(); + mapWindow.__hfGoogleMaps = [map]; + const adapter = createGoogleMapsAdapter(); + const promise = adapter.getReadyPromise!(); + expect(promise).not.toBeNull(); + map._fire("tilesloaded"); + await promise; + }); + + it("returns same promise on repeated calls (stable identity)", () => { + const map = createMockMap(); + mapWindow.__hfGoogleMaps = [map]; + const adapter = createGoogleMapsAdapter(); + const p1 = adapter.getReadyPromise!(); + const p2 = adapter.getReadyPromise!(); + expect(p1).toBe(p2); + }); + + it("returns null after all maps have settled", async () => { + const map = createMockMap(); + mapWindow.__hfGoogleMaps = [map]; + const adapter = createGoogleMapsAdapter(); + const promise = adapter.getReadyPromise!(); + map._fire("tilesloaded"); + await promise; + expect(adapter.getReadyPromise!()).toBeNull(); + }); + + it("removes listener after first tilesloaded fire", async () => { + const map = createMockMap(); + mapWindow.__hfGoogleMaps = [map]; + const adapter = createGoogleMapsAdapter(); + const promise = adapter.getReadyPromise!(); + map._fire("tilesloaded"); + await promise; + const handle = (map.addListener as ReturnType).mock.results[0].value; + expect(handle.remove).toHaveBeenCalled(); + }); + + it("handles multiple maps", async () => { + const map1 = createMockMap(); + const map2 = createMockMap(); + mapWindow.__hfGoogleMaps = [map1, map2]; + const adapter = createGoogleMapsAdapter(); + const promise = adapter.getReadyPromise!(); + map1._fire("tilesloaded"); + map2._fire("tilesloaded"); + await promise; + expect(adapter.getReadyPromise!()).toBeNull(); + }); + }); + + it("discover is a no-op", () => { + const adapter = createGoogleMapsAdapter(); + expect(() => adapter.discover()).not.toThrow(); + }); + + it("seek is a no-op", () => { + const adapter = createGoogleMapsAdapter(); + expect(() => adapter.seek({ time: 1 })).not.toThrow(); + }); +}); diff --git a/packages/core/src/runtime/adapters/google-maps.ts b/packages/core/src/runtime/adapters/google-maps.ts new file mode 100644 index 000000000..f65373598 --- /dev/null +++ b/packages/core/src/runtime/adapters/google-maps.ts @@ -0,0 +1,23 @@ +import { createReadinessAdapter } from "./_readiness"; + +type GoogleMapLike = { + addListener: (event: string, cb: () => void) => { remove: () => void }; +}; + +export function createGoogleMapsAdapter() { + return createReadinessAdapter({ + name: "google-maps", + getInstances: () => { + if (typeof window === "undefined") return []; + const arr = (window as { __hfGoogleMaps?: GoogleMapLike[] }).__hfGoogleMaps; + return Array.isArray(arr) ? arr : []; + }, + waitFor: (m) => + new Promise((resolve) => { + const handle = m.addListener("tilesloaded", () => { + handle.remove(); + resolve(); + }); + }), + }); +} diff --git a/packages/core/src/runtime/adapters/leaflet.test.ts b/packages/core/src/runtime/adapters/leaflet.test.ts new file mode 100644 index 000000000..d108fcbb4 --- /dev/null +++ b/packages/core/src/runtime/adapters/leaflet.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createLeafletAdapter } from "./leaflet"; + +type LeafletMapLike = { + whenReady: (cb: () => void) => void; +}; + +const mapWindow = window as Window & { __hfLeaflet?: LeafletMapLike[] }; + +function createMockMap(opts?: { ready?: boolean }): LeafletMapLike { + return { + whenReady: vi.fn((cb: () => void) => { + if (opts?.ready ?? false) cb(); + }), + _fireReady() { + const calls = (this.whenReady as ReturnType).mock.calls; + for (const [cb] of calls) cb(); + }, + } as LeafletMapLike & { _fireReady: () => void }; +} + +describe("leaflet adapter", () => { + beforeEach(() => { + delete mapWindow.__hfLeaflet; + }); + + afterEach(() => { + delete mapWindow.__hfLeaflet; + }); + + it("has correct name", () => { + expect(createLeafletAdapter().name).toBe("leaflet"); + }); + + describe("getReadyPromise", () => { + it("returns null when no maps registered", () => { + const adapter = createLeafletAdapter(); + expect(adapter.getReadyPromise!()).toBeNull(); + }); + + it("returns null when __hfLeaflet is empty", () => { + mapWindow.__hfLeaflet = []; + const adapter = createLeafletAdapter(); + expect(adapter.getReadyPromise!()).toBeNull(); + }); + + it("resolves when map fires whenReady callback", async () => { + const map = createMockMap() as LeafletMapLike & { _fireReady: () => void }; + mapWindow.__hfLeaflet = [map]; + const adapter = createLeafletAdapter(); + const promise = adapter.getReadyPromise!(); + expect(promise).not.toBeNull(); + map._fireReady(); + await promise; + }); + + it("resolves immediately for already-ready map", async () => { + const map = createMockMap({ ready: true }); + mapWindow.__hfLeaflet = [map]; + const adapter = createLeafletAdapter(); + const promise = adapter.getReadyPromise!(); + expect(promise).not.toBeNull(); + await promise; + }); + + it("returns same promise on repeated calls (stable identity)", () => { + const map = createMockMap(); + mapWindow.__hfLeaflet = [map]; + const adapter = createLeafletAdapter(); + const p1 = adapter.getReadyPromise!(); + const p2 = adapter.getReadyPromise!(); + expect(p1).toBe(p2); + }); + + it("returns null after all maps have settled", async () => { + const map = createMockMap({ ready: true }); + mapWindow.__hfLeaflet = [map]; + const adapter = createLeafletAdapter(); + await adapter.getReadyPromise!(); + expect(adapter.getReadyPromise!()).toBeNull(); + }); + }); + + it("discover is a no-op", () => { + const adapter = createLeafletAdapter(); + expect(() => adapter.discover()).not.toThrow(); + }); + + it("seek is a no-op", () => { + const adapter = createLeafletAdapter(); + expect(() => adapter.seek({ time: 1 })).not.toThrow(); + }); +}); diff --git a/packages/core/src/runtime/adapters/leaflet.ts b/packages/core/src/runtime/adapters/leaflet.ts new file mode 100644 index 000000000..047842488 --- /dev/null +++ b/packages/core/src/runtime/adapters/leaflet.ts @@ -0,0 +1,17 @@ +import { createReadinessAdapter } from "./_readiness"; + +type LeafletMapLike = { + whenReady: (cb: () => void) => void; +}; + +export function createLeafletAdapter() { + return createReadinessAdapter({ + name: "leaflet", + getInstances: () => { + if (typeof window === "undefined") return []; + const arr = (window as { __hfLeaflet?: LeafletMapLike[] }).__hfLeaflet; + return Array.isArray(arr) ? arr : []; + }, + waitFor: (m) => new Promise((resolve) => m.whenReady(resolve)), + }); +} diff --git a/packages/core/src/runtime/adapters/mapbox.test.ts b/packages/core/src/runtime/adapters/mapbox.test.ts new file mode 100644 index 000000000..64be43f32 --- /dev/null +++ b/packages/core/src/runtime/adapters/mapbox.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createMapboxAdapter } from "./mapbox"; + +type MapboxMapLike = { + loaded: () => boolean; + on: (event: string, cb: () => void) => void; +}; + +const mapWindow = window as Window & { __hfMapbox?: MapboxMapLike[] }; + +function createMockMap(opts?: { loaded?: boolean }): MapboxMapLike { + const listeners: Record void)[]> = {}; + return { + loaded: vi.fn(() => opts?.loaded ?? false), + on: vi.fn((event: string, cb: () => void) => { + (listeners[event] ??= []).push(cb); + }), + _fire(event: string) { + for (const cb of listeners[event] ?? []) cb(); + }, + } as MapboxMapLike & { _fire: (e: string) => void }; +} + +describe("mapbox adapter", () => { + beforeEach(() => { + delete mapWindow.__hfMapbox; + }); + + afterEach(() => { + delete mapWindow.__hfMapbox; + }); + + it("has correct name", () => { + expect(createMapboxAdapter().name).toBe("mapbox"); + }); + + describe("getReadyPromise", () => { + it("returns null when no maps registered", () => { + const adapter = createMapboxAdapter(); + expect(adapter.getReadyPromise!()).toBeNull(); + }); + + it("returns null when __hfMapbox is empty", () => { + mapWindow.__hfMapbox = []; + const adapter = createMapboxAdapter(); + expect(adapter.getReadyPromise!()).toBeNull(); + }); + + it("resolves when map fires load event", async () => { + const map = createMockMap() as MapboxMapLike & { _fire: (e: string) => void }; + mapWindow.__hfMapbox = [map]; + const adapter = createMapboxAdapter(); + const promise = adapter.getReadyPromise!(); + expect(promise).not.toBeNull(); + map._fire("load"); + await promise; + }); + + it("resolves immediately for already-loaded map", async () => { + const map = createMockMap({ loaded: true }); + mapWindow.__hfMapbox = [map]; + const adapter = createMapboxAdapter(); + const promise = adapter.getReadyPromise!(); + expect(promise).not.toBeNull(); + await promise; + }); + + it("returns same promise on repeated calls (stable identity)", () => { + const map = createMockMap(); + mapWindow.__hfMapbox = [map]; + const adapter = createMapboxAdapter(); + const p1 = adapter.getReadyPromise!(); + const p2 = adapter.getReadyPromise!(); + expect(p1).toBe(p2); + }); + + it("returns null after all maps have settled", async () => { + const map = createMockMap({ loaded: true }); + mapWindow.__hfMapbox = [map]; + const adapter = createMapboxAdapter(); + await adapter.getReadyPromise!(); + expect(adapter.getReadyPromise!()).toBeNull(); + }); + + it("handles mix of loaded and unloaded maps", async () => { + const loaded = createMockMap({ loaded: true }); + const unloaded = createMockMap() as MapboxMapLike & { _fire: (e: string) => void }; + mapWindow.__hfMapbox = [loaded, unloaded]; + const adapter = createMapboxAdapter(); + const promise = adapter.getReadyPromise!(); + expect(promise).not.toBeNull(); + unloaded._fire("load"); + await promise; + expect(adapter.getReadyPromise!()).toBeNull(); + }); + + it("resolves via loaded() check when map finishes loading before subscribe", async () => { + const racyMap = { + loaded: vi.fn(() => true), + on: vi.fn(), + }; + mapWindow.__hfMapbox = [racyMap]; + const adapter = createMapboxAdapter(); + const promise = adapter.getReadyPromise!(); + expect(promise).not.toBeNull(); + await promise; + expect(racyMap.on).not.toHaveBeenCalled(); + }); + }); + + it("discover is a no-op", () => { + const adapter = createMapboxAdapter(); + expect(() => adapter.discover()).not.toThrow(); + }); + + it("seek is a no-op", () => { + const adapter = createMapboxAdapter(); + expect(() => adapter.seek({ time: 1 })).not.toThrow(); + }); +}); diff --git a/packages/core/src/runtime/adapters/mapbox.ts b/packages/core/src/runtime/adapters/mapbox.ts new file mode 100644 index 000000000..5a6746032 --- /dev/null +++ b/packages/core/src/runtime/adapters/mapbox.ts @@ -0,0 +1,26 @@ +import { createReadinessAdapter } from "./_readiness"; + +type MapboxMapLike = { + loaded: () => boolean; + on: (event: string, cb: () => void) => void; +}; + +// 'load' = style + sources ready (not tile-level 'idle'); sufficient for render-ready gate +export function createMapboxAdapter() { + return createReadinessAdapter({ + name: "mapbox", + getInstances: () => { + if (typeof window === "undefined") return []; + const arr = (window as { __hfMapbox?: MapboxMapLike[] }).__hfMapbox; + return Array.isArray(arr) ? arr : []; + }, + waitFor: (m) => + new Promise((resolve) => { + if (m.loaded()) { + resolve(); + return; + } + m.on("load", resolve); + }), + }); +} diff --git a/packages/core/src/runtime/adapters/maplibre.test.ts b/packages/core/src/runtime/adapters/maplibre.test.ts new file mode 100644 index 000000000..ca6faaa03 --- /dev/null +++ b/packages/core/src/runtime/adapters/maplibre.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createMaplibreAdapter } from "./maplibre"; + +type MaplibreMapLike = { + loaded: () => boolean; + on: (event: string, cb: () => void) => void; +}; + +const mapWindow = window as Window & { __hfMaplibre?: MaplibreMapLike[] }; + +function createMockMap(opts?: { + loaded?: boolean; +}): MaplibreMapLike & { _fire: (e: string) => void } { + const listeners: Record void)[]> = {}; + return { + loaded: vi.fn(() => opts?.loaded ?? false), + on: vi.fn((event: string, cb: () => void) => { + (listeners[event] ??= []).push(cb); + }), + _fire(event: string) { + for (const cb of listeners[event] ?? []) cb(); + }, + }; +} + +describe("maplibre adapter", () => { + beforeEach(() => { + delete mapWindow.__hfMaplibre; + }); + + afterEach(() => { + delete mapWindow.__hfMaplibre; + }); + + it("has correct name", () => { + expect(createMaplibreAdapter().name).toBe("maplibre"); + }); + + describe("getReadyPromise", () => { + it("returns null when no maps registered", () => { + const adapter = createMaplibreAdapter(); + expect(adapter.getReadyPromise!()).toBeNull(); + }); + + it("returns null when __hfMaplibre is empty", () => { + mapWindow.__hfMaplibre = []; + const adapter = createMaplibreAdapter(); + expect(adapter.getReadyPromise!()).toBeNull(); + }); + + it("resolves when map fires load event", async () => { + const map = createMockMap(); + mapWindow.__hfMaplibre = [map]; + const adapter = createMaplibreAdapter(); + const promise = adapter.getReadyPromise!(); + expect(promise).not.toBeNull(); + map._fire("load"); + await promise; + }); + + it("resolves immediately for already-loaded map", async () => { + const map = createMockMap({ loaded: true }); + mapWindow.__hfMaplibre = [map]; + const adapter = createMaplibreAdapter(); + const promise = adapter.getReadyPromise!(); + expect(promise).not.toBeNull(); + await promise; + }); + + it("returns same promise on repeated calls (stable identity)", () => { + const map = createMockMap(); + mapWindow.__hfMaplibre = [map]; + const adapter = createMaplibreAdapter(); + const p1 = adapter.getReadyPromise!(); + const p2 = adapter.getReadyPromise!(); + expect(p1).toBe(p2); + }); + + it("returns null after all maps have settled", async () => { + const map = createMockMap({ loaded: true }); + mapWindow.__hfMaplibre = [map]; + const adapter = createMaplibreAdapter(); + await adapter.getReadyPromise!(); + expect(adapter.getReadyPromise!()).toBeNull(); + }); + + it("handles mix of loaded and unloaded maps", async () => { + const loaded = createMockMap({ loaded: true }); + const unloaded = createMockMap(); + mapWindow.__hfMaplibre = [loaded, unloaded]; + const adapter = createMaplibreAdapter(); + const promise = adapter.getReadyPromise!(); + expect(promise).not.toBeNull(); + unloaded._fire("load"); + await promise; + expect(adapter.getReadyPromise!()).toBeNull(); + }); + + it("resolves via loaded() check when map finishes loading before subscribe", async () => { + const racyMap = { + loaded: vi.fn(() => true), + on: vi.fn(), + }; + mapWindow.__hfMaplibre = [racyMap]; + const adapter = createMaplibreAdapter(); + const promise = adapter.getReadyPromise!(); + expect(promise).not.toBeNull(); + await promise; + expect(racyMap.on).not.toHaveBeenCalled(); + }); + }); + + it("discover is a no-op", () => { + const adapter = createMaplibreAdapter(); + expect(() => adapter.discover()).not.toThrow(); + }); + + it("seek is a no-op", () => { + const adapter = createMaplibreAdapter(); + expect(() => adapter.seek({ time: 1 })).not.toThrow(); + }); +}); diff --git a/packages/core/src/runtime/adapters/maplibre.ts b/packages/core/src/runtime/adapters/maplibre.ts new file mode 100644 index 000000000..69aee15ad --- /dev/null +++ b/packages/core/src/runtime/adapters/maplibre.ts @@ -0,0 +1,26 @@ +import { createReadinessAdapter } from "./_readiness"; + +type MaplibreMapLike = { + loaded: () => boolean; + on: (event: string, cb: () => void) => void; +}; + +// 'load' = style + sources ready; same contract as Mapbox GL JS +export function createMaplibreAdapter() { + return createReadinessAdapter({ + name: "maplibre", + getInstances: () => { + if (typeof window === "undefined") return []; + const arr = (window as { __hfMaplibre?: MaplibreMapLike[] }).__hfMaplibre; + return Array.isArray(arr) ? arr : []; + }, + waitFor: (m) => + new Promise((resolve) => { + if (m.loaded()) { + resolve(); + return; + } + m.on("load", resolve); + }), + }); +} diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index fb21ae723..bdc0e2c3a 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -6,6 +6,11 @@ import { createGsapAdapter } from "./adapters/gsap"; import { createAnimeJsAdapter } from "./adapters/animejs"; import { createLottieAdapter } from "./adapters/lottie"; import { createThreeAdapter } from "./adapters/three"; +import { createMapboxAdapter } from "./adapters/mapbox"; +import { createLeafletAdapter } from "./adapters/leaflet"; +import { createGoogleMapsAdapter } from "./adapters/google-maps"; +import { createMaplibreAdapter } from "./adapters/maplibre"; +import { createD3Adapter } from "./adapters/d3"; import { createTypegpuAdapter } from "./adapters/typegpu"; import { patchVideoTextureCompat, @@ -1913,6 +1918,11 @@ export function initSandboxRuntimeModular(): void { createAnimeJsAdapter(), createLottieAdapter(), createThreeAdapter(), + createMapboxAdapter(), + createLeafletAdapter(), + createGoogleMapsAdapter(), + createMaplibreAdapter(), + createD3Adapter(), createTypegpuAdapter(), createGsapAdapter({ getTimeline: () => state.capturedTimeline }), ] as RuntimeDeterministicAdapter[]; diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index 5d7467575..ff1f846e4 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -102,6 +102,36 @@ declare global { * window.__hfLottie.push(anim); */ __hfLottie?: unknown[]; + /** + * Mapbox GL JS map instances. Push your map here after creating it: + * window.__hfMapbox = window.__hfMapbox || []; + * window.__hfMapbox.push(map); + */ + __hfMapbox?: unknown[]; + /** + * Leaflet map instances. Push your map here after creating it: + * window.__hfLeaflet = window.__hfLeaflet || []; + * window.__hfLeaflet.push(map); + */ + __hfLeaflet?: unknown[]; + /** + * Google Maps instances. Push your map here after creating it: + * window.__hfGoogleMaps = window.__hfGoogleMaps || []; + * window.__hfGoogleMaps.push(map); + */ + __hfGoogleMaps?: unknown[]; + /** + * MapLibre GL JS map instances. Push your map here after creating it: + * window.__hfMaplibre = window.__hfMaplibre || []; + * window.__hfMaplibre.push(map); + */ + __hfMaplibre?: unknown[]; + /** + * D3 transition instances. Push your transition here after creating it: + * window.__hfD3 = window.__hfD3 || []; + * window.__hfD3.push(transition); + */ + __hfD3?: unknown[]; /** * Render-time variable overrides injected by the engine when the user * passes `hyperframes render --variables ''`. Read indirectly via diff --git a/packages/producer/tests/d3-adapter/meta.json b/packages/producer/tests/d3-adapter/meta.json new file mode 100644 index 000000000..6b09f3db7 --- /dev/null +++ b/packages/producer/tests/d3-adapter/meta.json @@ -0,0 +1,13 @@ +{ + "name": "d3-adapter", + "description": "Regression guard for the D3.js runtime adapter. Verifies that D3 transitions with an SVG bar chart register on window.__hfD3 and render deterministically via the adapter bridge.", + "tags": ["regression", "adapter"], + "minPsnr": 30, + "maxFrameFailures": 0, + "minAudioCorrelation": 0, + "maxAudioLagWindows": 1, + "renderConfig": { + "fps": 30, + "workers": 1 + } +} diff --git a/packages/producer/tests/d3-adapter/output/compiled.html b/packages/producer/tests/d3-adapter/output/compiled.html new file mode 100644 index 000000000..458742a99 --- /dev/null +++ b/packages/producer/tests/d3-adapter/output/compiled.html @@ -0,0 +1,208 @@ + + + + + + +
+ +
+
D3 adapter test
+
Animated bar chart with transitions
+ + + + +
+ + diff --git a/packages/producer/tests/d3-adapter/output/output.mp4 b/packages/producer/tests/d3-adapter/output/output.mp4 new file mode 100644 index 000000000..2659c36a6 --- /dev/null +++ b/packages/producer/tests/d3-adapter/output/output.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0693b24a6fad50609f05c4b5ea103a4e533113703725e64d9244435a1b0dbdcc +size 250658 diff --git a/packages/producer/tests/d3-adapter/src/index.html b/packages/producer/tests/d3-adapter/src/index.html new file mode 100644 index 000000000..93ae1d75b --- /dev/null +++ b/packages/producer/tests/d3-adapter/src/index.html @@ -0,0 +1,171 @@ + + + + + + +
+ +
+
D3 adapter test
+
Animated bar chart with transitions
+ + + + +
+ + diff --git a/packages/producer/tests/google-maps-adapter/meta.json b/packages/producer/tests/google-maps-adapter/meta.json new file mode 100644 index 000000000..07eebbfec --- /dev/null +++ b/packages/producer/tests/google-maps-adapter/meta.json @@ -0,0 +1,13 @@ +{ + "name": "google-maps-adapter", + "description": "Regression guard for the Google Maps runtime adapter. Uses a mock Google Maps object with procedural canvas visualization to verify the __hfGoogleMaps adapter bridge without requiring an API key.", + "tags": ["regression", "adapter"], + "minPsnr": 30, + "maxFrameFailures": 0, + "minAudioCorrelation": 0, + "maxAudioLagWindows": 1, + "renderConfig": { + "fps": 30, + "workers": 1 + } +} diff --git a/packages/producer/tests/google-maps-adapter/output/compiled.html b/packages/producer/tests/google-maps-adapter/output/compiled.html new file mode 100644 index 000000000..69a6ca602 --- /dev/null +++ b/packages/producer/tests/google-maps-adapter/output/compiled.html @@ -0,0 +1,224 @@ + + + + + + +
+ + +
Google Maps adapter test
+
tilesloaded
+ + + +
+ + diff --git a/packages/producer/tests/google-maps-adapter/output/output.mp4 b/packages/producer/tests/google-maps-adapter/output/output.mp4 new file mode 100644 index 000000000..c2e2a3f63 --- /dev/null +++ b/packages/producer/tests/google-maps-adapter/output/output.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a9fe32ca9852e9702e387332f9bc871314d2048fc19cd544db73fb169d18ae0 +size 155771 diff --git a/packages/producer/tests/google-maps-adapter/src/index.html b/packages/producer/tests/google-maps-adapter/src/index.html new file mode 100644 index 000000000..9138f1459 --- /dev/null +++ b/packages/producer/tests/google-maps-adapter/src/index.html @@ -0,0 +1,191 @@ + + + + + + +
+ + +
Google Maps adapter test
+
tilesloaded
+ + + +
+ + diff --git a/packages/producer/tests/leaflet-adapter/meta.json b/packages/producer/tests/leaflet-adapter/meta.json new file mode 100644 index 000000000..1659862e2 --- /dev/null +++ b/packages/producer/tests/leaflet-adapter/meta.json @@ -0,0 +1,13 @@ +{ + "name": "leaflet-adapter", + "description": "Regression guard for the Leaflet runtime adapter. Verifies that a real Leaflet map with OSM tiles initializes and registers on window.__hfLeaflet for deterministic frame capture.", + "tags": ["regression", "adapter"], + "minPsnr": 25, + "maxFrameFailures": 10, + "minAudioCorrelation": 0, + "maxAudioLagWindows": 1, + "renderConfig": { + "fps": 30, + "workers": 1 + } +} diff --git a/packages/producer/tests/leaflet-adapter/output/compiled.html b/packages/producer/tests/leaflet-adapter/output/compiled.html new file mode 100644 index 000000000..bc6c85bb0 --- /dev/null +++ b/packages/producer/tests/leaflet-adapter/output/compiled.html @@ -0,0 +1,107 @@ + + + + + + + +
+ +
+
Leaflet adapter test
+
37.7749, -122.4194 — San Francisco
+ + + + +
+ + diff --git a/packages/producer/tests/leaflet-adapter/output/output.mp4 b/packages/producer/tests/leaflet-adapter/output/output.mp4 new file mode 100644 index 000000000..ca71de54c --- /dev/null +++ b/packages/producer/tests/leaflet-adapter/output/output.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:11742cc93d05dde833246b259fe25fe93cde7f3d15c9907fa6838a57852822df +size 86363 diff --git a/packages/producer/tests/leaflet-adapter/src/index.html b/packages/producer/tests/leaflet-adapter/src/index.html new file mode 100644 index 000000000..0bcf0abca --- /dev/null +++ b/packages/producer/tests/leaflet-adapter/src/index.html @@ -0,0 +1,67 @@ + + + + + + + +
+ +
+
Leaflet adapter test
+
37.7749, -122.4194 — San Francisco
+ + + + +
+ + diff --git a/packages/producer/tests/mapbox-adapter/meta.json b/packages/producer/tests/mapbox-adapter/meta.json new file mode 100644 index 000000000..d45430221 --- /dev/null +++ b/packages/producer/tests/mapbox-adapter/meta.json @@ -0,0 +1,13 @@ +{ + "name": "mapbox-adapter", + "description": "Regression guard for the Mapbox GL JS runtime adapter. Uses a mock Mapbox map with procedural canvas visualization to verify the __hfMapbox adapter bridge without requiring an API key.", + "tags": ["regression", "adapter"], + "minPsnr": 30, + "maxFrameFailures": 0, + "minAudioCorrelation": 0, + "maxAudioLagWindows": 1, + "renderConfig": { + "fps": 30, + "workers": 1 + } +} diff --git a/packages/producer/tests/mapbox-adapter/output/compiled.html b/packages/producer/tests/mapbox-adapter/output/compiled.html new file mode 100644 index 000000000..c297955f5 --- /dev/null +++ b/packages/producer/tests/mapbox-adapter/output/compiled.html @@ -0,0 +1,166 @@ + + + + + + +
+ + +
Mapbox adapter test
+
mock: loaded
+ + + +
+ + diff --git a/packages/producer/tests/mapbox-adapter/output/output.mp4 b/packages/producer/tests/mapbox-adapter/output/output.mp4 new file mode 100644 index 000000000..34624b7c3 --- /dev/null +++ b/packages/producer/tests/mapbox-adapter/output/output.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf5c77b49eb2a7d0396b2798e872a4a6708dbe8c24be6c81afa896d86a1f7886 +size 213883 diff --git a/packages/producer/tests/mapbox-adapter/src/index.html b/packages/producer/tests/mapbox-adapter/src/index.html new file mode 100644 index 000000000..2f69a4e77 --- /dev/null +++ b/packages/producer/tests/mapbox-adapter/src/index.html @@ -0,0 +1,133 @@ + + + + + + +
+ + +
Mapbox adapter test
+
mock: loaded
+ + + +
+ + diff --git a/packages/producer/tests/maplibre-adapter/meta.json b/packages/producer/tests/maplibre-adapter/meta.json new file mode 100644 index 000000000..aeeb1fb86 --- /dev/null +++ b/packages/producer/tests/maplibre-adapter/meta.json @@ -0,0 +1,13 @@ +{ + "name": "maplibre-adapter", + "description": "Regression guard for the MapLibre GL JS runtime adapter. Verifies that a real MapLibre map with demo tiles initializes and registers on window.__hfMaplibre for deterministic frame capture.", + "tags": ["regression", "adapter"], + "minPsnr": 25, + "maxFrameFailures": 10, + "minAudioCorrelation": 0, + "maxAudioLagWindows": 1, + "renderConfig": { + "fps": 30, + "workers": 1 + } +} diff --git a/packages/producer/tests/maplibre-adapter/output/compiled.html b/packages/producer/tests/maplibre-adapter/output/compiled.html new file mode 100644 index 000000000..53c85d851 --- /dev/null +++ b/packages/producer/tests/maplibre-adapter/output/compiled.html @@ -0,0 +1,155 @@ + + + + + + + +
+ +
+
MapLibre adapter test
+
51.5074, -0.1278 — London
+ + + + +
+ + diff --git a/packages/producer/tests/maplibre-adapter/output/output.mp4 b/packages/producer/tests/maplibre-adapter/output/output.mp4 new file mode 100644 index 000000000..9f824a141 --- /dev/null +++ b/packages/producer/tests/maplibre-adapter/output/output.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:67f116ec5e15cb460d2926fd55ba1195cde6d05e519c6fe55fecc098ea966325 +size 98887 diff --git a/packages/producer/tests/maplibre-adapter/src/index.html b/packages/producer/tests/maplibre-adapter/src/index.html new file mode 100644 index 000000000..bd7a1e954 --- /dev/null +++ b/packages/producer/tests/maplibre-adapter/src/index.html @@ -0,0 +1,61 @@ + + + + + + + +
+ +
+
MapLibre adapter test
+
51.5074, -0.1278 — London
+ + + + +
+ +