From 48ddfbb3c2a6311833df14f17d9e56bf6d15e612 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 07:32:33 -0800 Subject: [PATCH] Fix example documentation snippets and validate generated code --- .gitignore | 2 + examples/basic/README.md | 11 ++ examples/basic/package.json | 2 + examples/basic/scripts/check-snippets.mjs | 62 +++++++ .../basic/scripts/snippet-interactions.mjs | 166 ++++++++++++++++++ .../basic/src/samples/documentation/index.ts | 50 +++++- .../documentation/pages/camera-restriction.ts | 27 +++ .../documentation/pages/camera-sync.ts | 82 ++++----- .../src/samples/documentation/pages/circle.ts | 3 +- .../samples/documentation/pages/fit-bounds.ts | 33 ++++ .../src/samples/documentation/pages/fly-to.ts | 7 +- .../documentation/pages/geojson-basic.ts | 52 +++--- .../documentation/pages/geojson-layer.ts | 59 ++++--- .../documentation/pages/ground-image.ts | 37 +++- .../documentation/pages/heatmap-layer.ts | 61 +++---- .../pages/info-bubble-multiple.ts | 12 +- .../documentation/pages/info-bubble-rich.ts | 9 +- .../documentation/pages/info-bubble-simple.ts | 2 +- .../documentation/pages/info-bubble-styled.ts | 8 +- .../samples/documentation/pages/kml-layer.ts | 73 ++++---- .../samples/documentation/pages/map-design.ts | 41 ++--- .../src/samples/documentation/pages/map.ts | 11 +- .../documentation/pages/marker-animation.ts | 13 +- .../pages/marker-label-position.ts | 4 + .../src/samples/documentation/pages/marker.ts | 9 +- .../documentation/pages/polygon-click.ts | 11 +- .../documentation/pages/polygon-geodesic.ts | 10 +- .../documentation/pages/polygon-hole.ts | 19 +- .../samples/documentation/pages/polygon.ts | 12 +- .../documentation/pages/polyline-click.ts | 7 +- .../samples/documentation/pages/polyline.ts | 12 +- .../pages/post-office-cluster.ts | 25 +-- .../documentation/pages/post-office.ts | 15 +- .../documentation/pages/raster-layer.ts | 18 +- .../documentation/pages/street-tree.ts | 28 +++ .../documentation/pages/style-adjustments.ts | 26 +++ .../documentation/pages/threejs-object.ts | 51 ++++-- .../src/samples/documentation/pages/tilt.ts | 14 +- .../documentation/pages/ui-settings.ts | 25 +++ .../documentation/pages/vector-tile.ts | 14 +- .../documentation/pages/visible-region.ts | 8 + .../samples/documentation/postOfficeData.ts | 15 ++ .../basic/src/samples/documentation/types.ts | 6 +- .../samples/sampleDocumentationProviders.ts | 69 ++++++-- package-lock.json | 51 ++++++ 45 files changed, 965 insertions(+), 307 deletions(-) create mode 100644 examples/basic/scripts/check-snippets.mjs create mode 100644 examples/basic/scripts/snippet-interactions.mjs create mode 100644 examples/basic/src/samples/documentation/pages/camera-restriction.ts create mode 100644 examples/basic/src/samples/documentation/pages/fit-bounds.ts create mode 100644 examples/basic/src/samples/documentation/pages/street-tree.ts create mode 100644 examples/basic/src/samples/documentation/pages/style-adjustments.ts create mode 100644 examples/basic/src/samples/documentation/pages/ui-settings.ts create mode 100644 examples/basic/src/samples/documentation/postOfficeData.ts diff --git a/.gitignore b/.gitignore index 97d3ae6..cf34e5e 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,8 @@ dist/* !react-for-template/test/*.mjs !examples/basic/server.mjs !examples/basic/scripts/generate-static.mjs +!examples/basic/scripts/check-snippets.mjs +!examples/basic/scripts/snippet-interactions.mjs # scripts/ はソースしか置かないので丸ごと追跡する。1 本ずつ negation を足す運用だと # 足し忘れたスクリプトが `git add -A` で無言のまま無視され、CI のチェックアウトにだけ # 存在しない状態になる(unlink-local-workspaces.mjs で実際に踏んだ)。 diff --git a/examples/basic/README.md b/examples/basic/README.md index 24d8db7..c0c364b 100644 --- a/examples/basic/README.md +++ b/examples/basic/README.md @@ -57,6 +57,17 @@ This project uses Vite's environment variable system. All environment variables - Never put sensitive secrets that should remain server-side in `VITE_` variables - For production deployment, set environment variables in your hosting platform +## Validate the displayed code examples + +From the workspace root, run `npm run check:snippets --workspace @mapconductor/example-basic`. +This checks the generated TSX for every supported page/provider pair, including imports, +and exercises data preparation and interaction handlers with real SDK state objects. +Provider rendering still requires a browser and any provider-specific credentials. + +The snippets are standalone `App.tsx` examples. They include small inline datasets; +the interactive pages may use larger datasets and additional controls. Replace `YOUR_…` +credential placeholders when using a provider that requires authentication. + ## Features - Switch between MapLibre and Google Maps providers diff --git a/examples/basic/package.json b/examples/basic/package.json index 93fdaa2..1f8458e 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -4,6 +4,7 @@ "private": true, "description": "Basic example app for MapConductor React SDK", "scripts": { + "check:snippets": "node scripts/check-snippets.mjs", "dev": "vite", "dev:https": "HTTPS=true vite", "build": "NODE_OPTIONS=--max-old-space-size=6144 vite build && NODE_OPTIONS=--max-old-space-size=6144 vite build --ssr src/entry-server.tsx --outDir dist/server && node scripts/generate-static.mjs", @@ -46,6 +47,7 @@ "devDependencies": { "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "@types/three": "^0.185.4", "@vitejs/plugin-react": "^5.2.0", "ts-node": "^10.9.2", "typescript": "^5.9.3", diff --git a/examples/basic/scripts/check-snippets.mjs b/examples/basic/scripts/check-snippets.mjs new file mode 100644 index 0000000..d1efcef --- /dev/null +++ b/examples/basic/scripts/check-snippets.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import ts from 'typescript'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { checkInteractions } from './snippet-interactions.mjs'; + +const root = fileURLToPath(new URL('../../../', import.meta.url)); +const result = await build({ + stdin: { + contents: `export { getSampleDocumentation } from './examples/basic/src/samples/documentation'; +export { SAMPLE_PAGES, resolveProviderForPage } from './examples/basic/src/samples/sampleRegistry'; +export { DOCUMENTATION_PROVIDERS } from './examples/basic/src/samples/sampleDocumentationProviders';`, + resolveDir: root, + }, + bundle: true, write: false, platform: 'node', format: 'esm', +}); +const { getSampleDocumentation, SAMPLE_PAGES, DOCUMENTATION_PROVIDERS, resolveProviderForPage } = + await import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}`); + +// Virtual files let TypeScript check exactly the text displayed to readers, +// without producing or maintaining a second copy of every example. +const snippets = new Map(); +for (const page of SAMPLE_PAGES) { + for (const provider of DOCUMENTATION_PROVIDERS) { + if (page.unavailableProviders?.includes(provider)) continue; + const resolved = resolveProviderForPage(provider, page.id); + const doc = getSampleDocumentation(page.id, resolved, 'ja'); + assert.match(doc.code, /export default function App\(\)/); + assert.doesNotMatch(doc.code, /MapViewContainer|providerDesignOptions|example\.com/); + assert.doesNotMatch(doc.code, /MapConductor overlays/, `${page.id} needs page-specific documentation`); + snippets.set(path.join(root, 'examples/basic', `__snippet_${page.id}_${provider}.tsx`), doc.code); + } +} +assert.match(getSampleDocumentation('marker', 'google').code, /GoogleMapView2D/); +assert.match(getSampleDocumentation('marker', 'google-3d').code, /GoogleMapView\b/); +const options = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + jsx: ts.JsxEmit.ReactJSX, + strict: true, noEmit: true, skipLibCheck: true, + noUnusedLocals: true, noUnusedParameters: true, + allowSyntheticDefaultImports: true, esModuleInterop: true, + types: ['vite/client'], +}; +const host = ts.createCompilerHost(options); +const readFile = host.readFile.bind(host); +const fileExists = host.fileExists.bind(host); +host.readFile = name => snippets.get(name) ?? readFile(name); +host.fileExists = name => snippets.has(name) || fileExists(name); +const program = ts.createProgram([...snippets.keys()], options, host); +const diagnostics = ts.getPreEmitDiagnostics(program).filter(d => !d.file || snippets.has(d.file.fileName)); +if (diagnostics.length) { + console.error(ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: name => name, getCurrentDirectory: () => root, getNewLine: () => '\n', + })); + process.exitCode = 1; +} else { + console.log(`Checked ${snippets.size} snippets (${SAMPLE_PAGES.length} pages, ${DOCUMENTATION_PROVIDERS.length} providers).`); + checkInteractions(getSampleDocumentation, SAMPLE_PAGES); +} diff --git a/examples/basic/scripts/snippet-interactions.mjs b/examples/basic/scripts/snippet-interactions.mjs new file mode 100644 index 0000000..ec9c55e --- /dev/null +++ b/examples/basic/scripts/snippet-interactions.mjs @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import ts from 'typescript'; + +const require = createRequire(import.meta.url); +const core = require('@mapconductor/js-sdk-core'); +const vectorStyle = await import('@mapconductor/js-vectorstyle'); +const extensionModules = Object.fromEntries(['geojson', 'kml', 'heatmap'].map(name => { + const source = `@mapconductor/react-${name}`; + return [source, { ...require(source), GeoJSONLayer: 'GeoJSONLayer', KMLLayer: 'KMLLayer', + HeatmapOverlay: 'HeatmapOverlay', HeatmapPoints: 'HeatmapPoints' }]; +})); + +/** Execute the displayed App with real SDK state objects and a small hook host. + * Maps remain element descriptors: these checks exercise example event handlers, + * not provider rendering (which needs a browser and, for some providers, keys). + */ +function mount(code) { + const slots = []; + let cursor = 0; + let effects = []; + const same = (a, b) => a && b && a.length === b.length && a.every((value, i) => Object.is(value, b[i])); + const hooks = { + useState(initial) { + const index = cursor++; + if (!(index in slots)) slots[index] = typeof initial === 'function' ? initial() : initial; + return [slots[index], value => { slots[index] = typeof value === 'function' ? value(slots[index]) : value; }]; + }, + useRef(initial) { return hooks.useMemo(() => ({ current: initial }), []); }, + useMemo(create, deps) { + const index = cursor++; + if (!slots[index] || !same(slots[index].deps, deps)) slots[index] = { deps, value: create() }; + return slots[index].value; + }, + useEffect(run, deps) { + const index = cursor++; + if (!slots[index] || !same(slots[index], deps)) { slots[index] = deps; effects.push(run); } + }, + }; + const jsx = (type, props, key) => ({ type, props: props ?? {}, key }); + const design = { id: 'normal' }; + const provider = new Proxy({}, { get: (_, key) => { + if (String(key).startsWith('use')) return params => hooks.useMemo(() => ({ + ...params, calls: [], + moveCameraTo(camera, duration) { this.cameraPosition = camera; this.calls.push({ camera, duration }); }, + fitBounds(bounds, padding) { this.calls.push({ bounds, padding }); }, + }), []); + if (String(key).includes('Design')) return { Normal: design, OsmBrightJa: design, OpenStreetMap: design }; + return key; + } }); + const componentNames = new Proxy({}, { get: (_, key) => key }); + const output = ts.transpileModule(code, { + compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const exports = {}; + // Image encoding is verified in Chrome; the hook host only needs a canvas stub. + const document = { createElement(tag) { + assert.equal(tag, 'canvas'); + return { getContext: () => ({ fillRect() {}, fillText() {} }), + toDataURL: format => { assert.equal(format, 'image/png'); return 'data:image/png;base64,test'; } }; + } }; + new Function('require', 'exports', 'document', output)(source => { + if (source === 'react') return hooks; + if (source === 'react/jsx-runtime') return { jsx, jsxs: jsx }; + if (source.endsWith('.css')) return {}; + if (source === '@mapconductor/js-sdk-core') return core; + if (source === '@mapconductor/js-vectorstyle') return vectorStyle; + if (source === '@mapconductor/js-sdk-react') return componentNames; + if (extensionModules[source]) return extensionModules[source]; + if (source.startsWith('@mapconductor/react-for-')) return provider; + if (source === '@mapconductor/react-vectortile' || source === '@mapconductor/react-marker-clustering') return componentNames; + return require(source); + }, exports, document); + const render = () => { + cursor = 0; effects = []; + const tree = exports.default(); + effects.forEach(run => run()); + return tree; + }; + return { render }; +} + +function elements(tree, type) { + if (Array.isArray(tree)) return tree.flatMap(child => elements(child, type)); + if (!tree || typeof tree !== 'object') return []; + return [...(tree.type === type ? [tree] : []), ...elements(tree.props?.children, type)]; +} + +export function checkInteractions(getDocumentation, pages) { + const sample = page => mount(getDocumentation(page, 'maplibre', 'ja').code); + // Every App can prepare its own data without a surrounding example application. + for (const page of pages) assert.ok(sample(page.id).render(), page.id); + + for (const [page, shape, field] of [['polyline', 'Polyline', 'points'], ['polygon', 'Polygon', 'points'], ['polygon-hole', 'Polygon', 'holes']]) { + const app = sample(page); + let tree = app.render(); + const marker = elements(tree, 'Markers')[0].props.states[0]; + const moved = core.createGeoPoint({ latitude: 35.675, longitude: 139.775 }); + marker.position = moved; + marker.onDrag(marker); + tree = app.render(); + const geometry = elements(tree, shape)[0].props.state[field]; + assert.equal(field === 'holes' ? geometry[0][0] : geometry[0], moved, `${page} updates geometry`); + assert.equal(elements(tree, 'Markers')[0].props.states[0], marker, `${page} retains the dragged marker`); + } + + const multiple = sample('info-bubble-multiple'); + const selected = elements(multiple.render(), 'Markers')[0].props.states[0]; + selected.onClick(selected); selected.onClick(selected); + assert.equal(elements(multiple.render(), 'InfoBubble').length, 1, 'clicking twice does not duplicate a bubble'); + + const circle = sample('circle'); + const edge = elements(circle.render(), 'Marker').find(item => item.props.draggable); + edge.props.onDrag(core.createMarkerState({ position: core.Spherical.computeOffset({ + origin: edge.props.position, distance: 1000, heading: 90, + }) })); + assert.ok(elements(circle.render(), 'Circle')[0].props.state.radiusMeters > 1500, 'circle resizes'); + + for (const [page, type] of [['geojson-basic', 'GeoJSONLayer'], ['geojson-layer', 'GeoJSONLayer'], ['kml-layer', 'KMLLayer']]) { + assert.ok(elements(sample(page).render(), type)[0].props.features.length, `${page} has actual features`); + } + + const raster = sample('raster-layer'); + let tree = raster.render(); + const layer = elements(tree, 'RasterLayer')[0].props.state; + elements(tree, 'input')[0].props.onChange({ target: { value: '0.4' } }); + tree = raster.render(); + assert.equal(elements(tree, 'RasterLayer')[0].props.state, layer); + assert.equal(layer.opacity, 0.4, 'opacity updates the existing raster state'); + + const tilt = sample('tilt'); + tree = tilt.render(); + const map = elements(tree, 'MapLibreMapView2D')[0].props.state; + assert.equal(map.calls.length, 0, 'rendering does not move the camera'); + elements(tree, 'input')[0].props.onChange({ target: { value: '40' } }); + assert.equal(map.calls[0].camera.tilt, 40); + tilt.render(); + assert.equal(map.calls.length, 1, 'rerendering does not repeat camera movement'); + + const sync = sample('camera-sync'); + tree = sync.render(); + const left = elements(tree, 'MapLibreMapView2D')[0]; + const right = elements(tree, 'LeafletMapView')[0]; + const panes = elements(tree, 'div').filter(item => item.props.onWheel); + panes[0].props.onWheel(); + left.props.onCameraMove(left.props.state.cameraPosition); + right.props.onCameraMove(right.props.state.cameraPosition); + assert.equal(right.props.state.calls.length, 1); + assert.equal(left.props.state.calls.length, 0, 'mirrored camera events do not echo'); + panes[1].props.onFocusCapture(); + right.props.onCameraMove(right.props.state.cameraPosition); + assert.equal(left.props.state.calls.length, 1, 'either map can become the source'); + + const ground = sample('ground-image'); + tree = ground.render(); + const image = elements(tree, 'GroundImage')[0].props.state; + assert.match(image.imageUrl, /^data:image\/png;/, 'ground image uses a raster image that MapLibre can decode'); + const corners = elements(tree, 'Markers')[0].props.states; + corners[0].position = core.createGeoPoint({ latitude: -26.485, longitude: 31.299 }); + corners[0].onDrag(corners[0]); + corners[1].position = core.createGeoPoint({ latitude: -26.473, longitude: 31.313 }); + corners[1].onDrag(corners[1]); + assert.equal(image.bounds.southWest, corners[0].position); + assert.equal(image.bounds.northEast, corners[1].position); + console.log('Checked snippet data and interaction handlers.'); +} diff --git a/examples/basic/src/samples/documentation/index.ts b/examples/basic/src/samples/documentation/index.ts index 3be4ffe..584ead9 100644 --- a/examples/basic/src/samples/documentation/index.ts +++ b/examples/basic/src/samples/documentation/index.ts @@ -38,6 +38,11 @@ import geojsonLayer from './pages/geojson-layer'; import kmlLayer from './pages/kml-layer'; import heatmapLayer from './pages/heatmap-layer'; import threejsObject from './pages/threejs-object'; +import fitBounds from './pages/fit-bounds'; +import cameraRestriction from './pages/camera-restriction'; +import uiSettings from './pages/ui-settings'; +import streetTree from './pages/street-tree'; +import styleAdjustments from './pages/style-adjustments'; export type { SamplePageDoc } from './types'; @@ -74,6 +79,11 @@ const DOCUMENTATION: Record = { 'kml-layer': kmlLayer, 'heatmap-layer': heatmapLayer, 'threejs-object': threejsObject, + 'fit-bounds': fitBounds, + 'camera-restriction': cameraRestriction, + 'ui-settings': uiSettings, + 'street-tree': streetTree, + 'style-adjustments': styleAdjustments, }; const FALLBACK: SamplePageDoc = { @@ -129,6 +139,7 @@ const SECTION_COMMENTS: Record').join(``); } @@ -149,6 +160,8 @@ const IMPORT_DEFINITIONS: readonly ImportDefinition[] = [ values: [ 'ColorDefaultIcon', 'DefaultMarkerIcon', + 'ImageIcon', + 'MapUISettings', 'MarkerAnimation', 'MarkerTilingOptions', 'PolygonManager', @@ -195,9 +208,17 @@ const IMPORT_DEFINITIONS: readonly ImportDefinition[] = [ ...PROVIDER_IMPORTS, { source: '@mapconductor/react-geojson', - values: ['GeoJSONLayer', 'GeoJSONLayerState', 'colorArgb'], + values: ['GeoJSONLayer', 'GeoJSONLayerState', 'GeoJSONParser', 'colorArgb'], types: ['GeoJSONFeatureData'], }, + { + source: '@mapconductor/react-vectortile', + values: ['VectorTileLayer'], + }, + { + source: '@mapconductor/js-vectorstyle', + values: ['buildStyleRules', 'LayerRole'], + }, { source: '@mapconductor/react-kml', values: ['KMLLayer', 'KMLLayerState', 'KMLParser', 'KMLLoader'], @@ -210,11 +231,14 @@ const IMPORT_DEFINITIONS: readonly ImportDefinition[] = [ { source: '@mapconductor/react-marker-clustering', values: ['MarkerClusterGroup'], + types: ['MarkerCluster'], }, ]; function usesIdentifier(code: string, identifier: string): boolean { - return new RegExp(`\\b${identifier}\\b`).test(code); + // Names in comments and data (e.g. GeoJSON's type: 'Polygon') are not imports. + const source = code.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|`(?:\\.|[^`\\])*`/g, ''); + return new RegExp(`\\b${identifier}\\b`).test(source); } function formatImport(definition: ImportDefinition, code: string): string | null { @@ -238,6 +262,13 @@ function importCode(code: string): string { if (usesIdentifier(code, 'THREE')) { imports.splice(1, 0, "import * as THREE from 'three';"); } + for (const definition of PROVIDER_IMPORTS) { + if (['maplibre', 'mapbox', 'leaflet', 'openlayers', 'maptiler', 'azuremaps', 'cesium'] + .some(name => definition.source === `@mapconductor/react-for-${name}`) + && definition.values.some(name => usesIdentifier(code, name))) { + imports.push(`import '${definition.source}/style.css';`); + } + } return imports.join('\n'); } @@ -247,20 +278,25 @@ export function getSampleDocumentation( language: SupportedLanguage = 'en', ): SamplePageDoc { const documentation = DOCUMENTATION[page ?? ''] ?? FALLBACK; - const provider = providerCode(providerName); + const provider = providerCode(providerName, documentation.camera); const providerSetup = page === 'camera-sync' - ? initialCameraCode() + ? initialCameraCode(documentation.camera) : provider.stateSetup; const comments = SECTION_COMMENTS[language]; const stateSection = documentation.state - ? [`// (2) ${comments.state}`, documentation.state].join('\n') + ? [`// (2) ${comments.state}`, adaptProviderView(documentation.state, provider)].join('\n') : ''; - const mainCode = [ + const body = [ `// (1) ${comments.map}`, providerSetup, stateSection, `// (${documentation.state ? 3 : 2}) ${comments.render}`, - adaptProviderView(documentation.code, provider), + `return (\n
\n${adaptProviderView(documentation.code, provider).split('\n').map(line => ` ${line}`).join('\n')}\n
\n);`, + ].filter(Boolean).join('\n\n'); + const mainCode = [ + page === 'camera-sync' ? undefined : provider.prerequisites, + documentation.helpers, + `export default function App() {\n${body.split('\n').map(line => line ? ` ${line}` : '').join('\n')}\n}`, ].filter(Boolean).join('\n\n'); return { ...documentation, diff --git a/examples/basic/src/samples/documentation/pages/camera-restriction.ts b/examples/basic/src/samples/documentation/pages/camera-restriction.ts new file mode 100644 index 0000000..d55c8ca --- /dev/null +++ b/examples/basic/src/samples/documentation/pages/camera-restriction.ts @@ -0,0 +1,27 @@ +import type { SamplePageDoc } from '../types'; + +const doc: SamplePageDoc = { + state: `const [enabled, setEnabled] = useState(true); +const restriction = useMemo(() => ({ + bounds: createGeoRectBounds({ + southWest: createGeoPoint({ latitude: 35.63, longitude: 139.70 }), + northEast: createGeoPoint({ latitude: 35.75, longitude: 139.85 }), + }), + minZoom: 12, maxZoom: 16, +}), []);`, + code: ` + +`, + explanation: { + en: ['cameraRestriction limits the camera position and zoom. Toggle the restriction and request a position outside Tokyo to compare the behavior. Providers may reject the move or clamp it to the permitted range.'], + ja: ['cameraRestriction で移動可能な範囲とズームの上下限を指定します。制限の有無を切り替え、範囲外への移動を試せます。プロバイダーによって、移動を拒否するか、許可範囲へ補正します。'], + 'es-419': ['cameraRestriction limita la posición y el zoom. Activa la restricción y solicita una posición fuera de Tokio. El proveedor puede rechazar el movimiento o ajustarlo al rango permitido.'], + de: ['cameraRestriction begrenzt Position und Zoom. Schalten Sie die Begrenzung um und fordern Sie eine Position außerhalb Tokios an. Der Anbieter lehnt die Bewegung ab oder korrigiert sie auf den erlaubten Bereich.'], + th: ['cameraRestriction จำกัดตำแหน่งและซูม ลองสลับข้อจำกัดและขอเคลื่อนที่ออกนอกโตเกียว ผู้ให้บริการอาจปฏิเสธการเคลื่อนที่หรือปรับกลับเข้าสู่ขอบเขตที่อนุญาต'], + hi: ['cameraRestriction स्थिति और ज़ूम सीमित करता है। सीमा चालू करके टोक्यो के बाहर की स्थिति माँगें। प्रोवाइडर बदलाव रोक सकता है या अनुमत सीमा में सुधार सकता है।'], + }, +}; +export default doc; diff --git a/examples/basic/src/samples/documentation/pages/camera-sync.ts b/examples/basic/src/samples/documentation/pages/camera-sync.ts index ea06a78..b45e002 100644 --- a/examples/basic/src/samples/documentation/pages/camera-sync.ts +++ b/examples/basic/src/samples/documentation/pages/camera-sync.ts @@ -1,28 +1,17 @@ import type { SamplePageDoc } from '../types'; const doc: SamplePageDoc = { - code: `const syncCamera = ( - source: 'left' | 'right', - camera: MapCameraPosition, -) => { - const targetState = source === 'left' ? rightMapState : leftMapState; - const targetGuard = source === 'left' ? rightProgrammatic : leftProgrammatic; - if (targetGuard.current) return; - - targetGuard.current = true; - targetState.moveCameraTo(camera, 0); - requestAnimationFrame(() => { targetGuard.current = false; }); -}; - -
- syncCamera('left', camera)} - /> - syncCamera('right', camera)} - /> + code: `
+
{ activeMap.current = 'left'; }} + onWheel={() => { activeMap.current = 'left'; }} onFocusCapture={() => { activeMap.current = 'left'; }}> + syncCamera('left', camera)} /> +
+
{ activeMap.current = 'right'; }} + onWheel={() => { activeMap.current = 'right'; }} onFocusCapture={() => { activeMap.current = 'right'; }}> + syncCamera('right', camera)} /> +
`, state: `const leftMapState = useMapLibreViewState({ mapDesignType: MapLibreDesign.OsmBrightJa, @@ -32,38 +21,37 @@ const rightMapState = useLeafletMapViewState({ mapDesignType: LeafletDesign.OpenStreetMap, cameraPosition: initialCamera, }); -const leftProgrammatic = useRef(false); -const rightProgrammatic = useRef(false);`, +// Only the map being manipulated forwards camera events. Mirrored events cannot echo. +const activeMap = useRef<'left' | 'right' | null>(null); +const syncCamera = (source: 'left' | 'right', camera: MapCameraPosition) => { + if (activeMap.current !== source) return; + const targetState = source === 'left' ? rightMapState : leftMapState; + targetState.moveCameraTo(createMapCameraPosition({ position: camera.position, zoom: camera.zoom }), 0); +};`, explanation: { - en: [ - 'Forward camera changes between two independently rendered providers while suppressing feedback loops.', - 'This page intentionally runs two providers at once: useMapLibreViewState and useLeafletMapViewState each build a separate view state that starts from the same initialCamera.', - "syncCamera copies one map's camera onto the other with moveCameraTo(camera, 0), and a useRef guard released on the next requestAnimationFrame stops the mirrored update from echoing back.", + 'en': [ + 'Forward camera changes between MapLibre and Leaflet without a feedback loop.', + 'activeMap records which map receives pointer input. Only that map forwards its position and zoom with moveCameraTo; events from the other map are ignored.', ], - ja: [ - '独立して描画した2つのプロバイダー間でカメラ変更を転送し、相互更新のループを抑制します。', - 'このページだけは2種類のプロバイダーを同時に使います。useMapLibreViewState と useLeafletMapViewState が同じ initialCamera から別々の ViewState を作ります。', - 'syncCamera は一方のカメラを moveCameraTo(camera, 0) でもう一方へ転写し、useRef のガードを次の requestAnimationFrame で解除することで、転写した更新が跳ね返るのを防ぎます。', + 'ja': [ + 'MapLibre と Leaflet の間でカメラ位置とズームを同期します。', + 'ポインター操作を受けた地図を activeMap に記録し、その地図のカメライベントだけを転送します。移動先から返るイベントは転送しないため、相互更新がループしません。', ], 'es-419': [ - 'Transfiere los cambios de cámara entre dos proveedores renderizados de forma independiente y evita ciclos de actualización.', - 'Esta página usa dos proveedores a la vez a propósito: useMapLibreViewState y useLeafletMapViewState crean cada uno un estado de vista separado que parte de la misma initialCamera.', - 'syncCamera copia la cámara de un mapa sobre el otro con moveCameraTo(camera, 0), y un guard con useRef liberado en el siguiente requestAnimationFrame impide que la actualización reflejada rebote.', + 'Sincroniza la posición y el zoom entre MapLibre y Leaflet.', + 'activeMap registra el mapa que recibe la entrada del puntero. Solo ese mapa envía cambios mediante moveCameraTo; se ignoran los eventos del otro mapa.', ], - de: [ - 'Kameraänderungen zwischen zwei unabhängig gerenderten Anbietern weiterreichen und dabei Rückkopplungsschleifen unterdrücken.', - 'Diese Seite betreibt bewusst zwei Anbieter gleichzeitig: useMapLibreViewState und useLeafletMapViewState bauen je einen eigenen View-State auf, der von derselben initialCamera ausgeht.', - 'syncCamera überträgt die Kamera der einen Karte mit moveCameraTo(camera, 0) auf die andere, und eine useRef-Sperre, die erst im nächsten requestAnimationFrame gelöst wird, verhindert, dass die gespiegelte Aktualisierung zurückhallt.', + 'de': [ + 'Position und Zoom zwischen MapLibre und Leaflet synchronisieren.', + 'activeMap merkt sich die Karte mit der Zeigereingabe. Nur diese Karte leitet Änderungen mit moveCameraTo weiter; Ereignisse der anderen Karte werden ignoriert.', ], - th: [ - 'ส่งต่อการเปลี่ยนแปลงของกล้องระหว่างผู้ให้บริการสองรายที่เรนเดอร์แยกกัน พร้อมกันไม่ให้เกิดลูปป้อนกลับ', - 'หน้านี้ตั้งใจให้ผู้ให้บริการสองรายทำงานพร้อมกัน โดย useMapLibreViewState และ useLeafletMapViewState ต่างสร้างสถานะมุมมองของตัวเองที่เริ่มจาก initialCamera เดียวกัน', - 'syncCamera คัดลอกกล้องของแผนที่ฝั่งหนึ่งไปยังอีกฝั่งด้วย moveCameraTo(camera, 0) และตัวกันด้วย useRef ที่ปลดใน requestAnimationFrame ถัดไปจะหยุดไม่ให้การอัปเดตที่สะท้อนกลับวนซ้ำ', + 'th': [ + 'ซิงก์ตำแหน่งและระดับซูมระหว่าง MapLibre กับ Leaflet', + 'activeMap บันทึกแผนที่ที่รับการควบคุมจากตัวชี้ เฉพาะแผนที่นั้นจะส่งการเปลี่ยนแปลงด้วย moveCameraTo และละเว้นเหตุการณ์จากอีกแผนที่', ], - hi: [ - 'अलग-अलग रेंडर होने वाले दो प्रोवाइडर के बीच कैमरे के बदलाव आगे भेजें, और फ़ीडबैक लूप को रोकें।', - 'यह पेज जानबूझकर दो प्रोवाइडर एक साथ चलाता है: useMapLibreViewState और useLeafletMapViewState दोनों अपना-अपना व्यू स्टेट बनाते हैं, जो एक ही initialCamera से शुरू होता है।', - 'syncCamera एक मैप का कैमरा moveCameraTo(camera, 0) से दूसरे पर उतार देता है, और अगले requestAnimationFrame पर छूटने वाला useRef का पहरा उस नकल हुए अपडेट को वापस गूँजने नहीं देता।', + 'hi': [ + 'MapLibre और Leaflet के बीच स्थिति और ज़ूम सिंक करें।', + 'activeMap पॉइंटर इनपुट पाने वाला मैप याद रखता है। सिर्फ़ वही मैप moveCameraTo से बदलाव भेजता है; दूसरे मैप के इवेंट अनदेखे होते हैं।', ], }, }; diff --git a/examples/basic/src/samples/documentation/pages/circle.ts b/examples/basic/src/samples/documentation/pages/circle.ts index 25f961c..70a063a 100644 --- a/examples/basic/src/samples/documentation/pages/circle.ts +++ b/examples/basic/src/samples/documentation/pages/circle.ts @@ -1,13 +1,14 @@ import type { SamplePageDoc } from '../types'; const doc: SamplePageDoc = { + camera: { latitude: 21.382314, longitude: -157.933097, zoom: 13 }, code: ` `, - state: `const center = createGeoPoint({ latitude: 21.382314, longitude: -157.933097 }); + state: `const center = useMemo(() => createGeoPoint({ latitude: 21.382314, longitude: -157.933097 }), []); const [edge, setEdge] = useState(() => Spherical.computeOffset({ origin: center, distance: 1000, heading: 90, })); diff --git a/examples/basic/src/samples/documentation/pages/fit-bounds.ts b/examples/basic/src/samples/documentation/pages/fit-bounds.ts new file mode 100644 index 0000000..2f411f2 --- /dev/null +++ b/examples/basic/src/samples/documentation/pages/fit-bounds.ts @@ -0,0 +1,33 @@ +import type { SamplePageDoc } from '../types'; + +const doc: SamplePageDoc = { + state: `const bounds = useMemo(() => createGeoRectBounds({ + southWest: createGeoPoint({ latitude: 35.66, longitude: 139.74 }), + northEast: createGeoPoint({ latitude: 35.70, longitude: 139.80 }), +}), []); +const marker = useMemo(() => createMarkerState({ + position: createGeoPoint({ latitude: 35.68, longitude: 139.76 }), + draggable: true, + onDragStart: state => { + bounds.southWest = state.position; + bounds.northEast = state.position; + }, + onDragEnd: state => { + bounds.extend(state.position); + mapViewState.fitBounds(bounds, 40); + }, +}), [bounds, mapViewState]);`, + code: ` + + +`, + explanation: { + en: ['fitBounds adjusts the camera to a geographic rectangle with 40 pixels of padding. Drag the marker to fit the rectangle between its start and end positions.'], + ja: ['fitBounds は指定した矩形が収まるようにカメラを調整します。第2引数は余白のピクセル数です。マーカーをドラッグすると、開始位置と終了位置を含む範囲へ移動します。'], + 'es-419': ['fitBounds ajusta la cámara a un rectángulo geográfico con 40 píxeles de margen. Arrastra el marcador para ajustar el rectángulo entre el inicio y el final del movimiento.'], + de: ['fitBounds passt die Kamera mit 40 Pixeln Rand an ein geografisches Rechteck an. Ziehen Sie den Marker, um den Bereich zwischen Start und Ende einzupassen.'], + th: ['fitBounds ปรับกล้องให้ครอบคลุมสี่เหลี่ยมภูมิศาสตร์พร้อมขอบ 40 พิกเซล ลากมาร์กเกอร์เพื่อปรับให้พอดีกับพื้นที่ระหว่างจุดเริ่มต้นและจุดสิ้นสุด'], + hi: ['fitBounds कैमरे को 40 पिक्सेल के अंतर के साथ भौगोलिक आयत में फ़िट करता है। मार्कर खींचने पर आरंभ और अंत के बीच का क्षेत्र दिखता है।'], + }, +}; +export default doc; diff --git a/examples/basic/src/samples/documentation/pages/fly-to.ts b/examples/basic/src/samples/documentation/pages/fly-to.ts index 4958f65..79d275e 100644 --- a/examples/basic/src/samples/documentation/pages/fly-to.ts +++ b/examples/basic/src/samples/documentation/pages/fly-to.ts @@ -1,6 +1,7 @@ import type { SamplePageDoc } from '../types'; const doc: SamplePageDoc = { + camera: { latitude: 35.65, longitude: 139.70, zoom: 10 }, code: ` @@ -8,11 +9,11 @@ const doc: SamplePageDoc = { `, - state: `const destination = createMapCameraPosition({ + state: `const destination = useMemo(() => createMapCameraPosition({ position: createGeoPoint({ latitude: 35.6812, longitude: 139.7671 }), zoom: 13, -}); -const destinations = [createMarkerState({ id: 'tokyo', position: destination.position })];`, +}), []); +const destinations = useMemo(() => [createMarkerState({ id: 'tokyo', position: destination.position })], [destination]);`, explanation: { en: [ 'Move the camera to a destination with a one-second animation while using the same API for every provider.', diff --git a/examples/basic/src/samples/documentation/pages/geojson-basic.ts b/examples/basic/src/samples/documentation/pages/geojson-basic.ts index 025c222..5b117b6 100644 --- a/examples/basic/src/samples/documentation/pages/geojson-basic.ts +++ b/examples/basic/src/samples/documentation/pages/geojson-basic.ts @@ -4,42 +4,46 @@ const doc: SamplePageDoc = { code: ` `, - state: `const [features, setFeatures] = useState([]); + state: `// GeoJSON coordinates use [longitude, latitude]. Rings must be closed. +const features = useMemo(() => GeoJSONParser.parseFeatures(JSON.stringify({ + type: 'FeatureCollection', + features: [{ + type: 'Feature', properties: { name: 'Area with a hole' }, + geometry: { type: 'Polygon', coordinates: [ + [[139.74, 35.70], [139.74, 35.66], [139.80, 35.66], [139.80, 35.70], [139.74, 35.70]], + [[139.76, 35.69], [139.78, 35.68], [139.76, 35.67], [139.76, 35.69]], + ] }, + }], +})), []); const layer = useMemo(() => new GeoJSONLayerState({ fillColor: colorArgb(127, 0x3b, 0xb2, 0xd0), strokeColor: colorArgb(255, 0x1d, 0x70, 0x82), strokeWidth: 2, }), []);`, explanation: { - en: [ - 'Load GeoJSON features and render them with one provider-independent layer style.', - 'The features live in React state and layer is a GeoJSONLayerState created once with useMemo.', - 'Passing both to draws every feature with a single shared style that looks the same on any provider.', + 'en': [ + 'GeoJSONParser.parseFeatures converts inline GeoJSON into layer features. Coordinates use longitude before latitude, and polygon rings must be closed.', + 'GeoJSONLayerState supplies the shared fill and stroke. GeoJSONLayer renders the polygon and its hole from the memoized features.', ], - ja: [ - 'GeoJSON の Feature を読み込み、プロバイダーに依存しない1つのレイヤースタイルで描画します。', - 'features は React の state に保持し、layer は useMemo で一度だけ生成した GeoJSONLayerState です。', - '両方を へ渡すと、すべての Feature が共有の1スタイルで描画され、どのプロバイダーでも同じ見た目になります。', + 'ja': [ + 'GeoJSONParser.parseFeatures でコード内の GeoJSON を Feature 配列に変換します。座標は経度・緯度の順で、ポリゴンの各リングは始点と終点を一致させます。', + 'GeoJSONLayerState に塗りと線を設定し、useMemo で保持した features とともに GeoJSONLayer へ渡すと、穴付きポリゴンを描画できます。', ], 'es-419': [ - 'Carga elementos GeoJSON y los dibuja con un estilo de capa independiente del proveedor.', - 'Los features viven en el estado de React y layer es un GeoJSONLayerState creado una sola vez con useMemo.', - 'Pasar ambos a dibuja cada elemento con un único estilo compartido que se ve igual en cualquier proveedor.', + 'GeoJSONParser.parseFeatures convierte el GeoJSON incluido en elementos de capa. Las coordenadas usan longitud antes de latitud y los anillos deben estar cerrados.', + 'GeoJSONLayerState define el relleno y el trazo. GeoJSONLayer dibuja el polígono con su hueco.', ], - de: [ - 'GeoJSON-Features laden und mit einem einzigen anbieterunabhängigen Ebenenstil zeichnen.', - 'Die Features liegen im React-State, und layer ist ein einmalig mit useMemo erzeugter GeoJSONLayerState.', - 'Beides an übergeben zeichnet jedes Feature mit einem gemeinsamen Stil, der bei jedem Anbieter gleich aussieht.', + 'de': [ + 'GeoJSONParser.parseFeatures wandelt eingebettetes GeoJSON in Features um. Koordinaten stehen als Länge vor Breite; Polygonringe müssen geschlossen sein.', + 'GeoJSONLayerState definiert Füllung und Kontur. GeoJSONLayer zeichnet das Polygon mit seinem Loch.', ], - th: [ - 'โหลดฟีเจอร์ GeoJSON แล้ววาดด้วยสไตล์เลเยอร์ชุดเดียวที่ไม่ผูกกับผู้ให้บริการ', - 'ฟีเจอร์เก็บอยู่ในสถานะของ React ส่วน layer คือ GeoJSONLayerState ที่สร้างครั้งเดียวด้วย useMemo', - 'การส่งทั้งสองเข้า จะวาดทุกฟีเจอร์ด้วยสไตล์ร่วมชุดเดียว ซึ่งหน้าตาเหมือนกันบนผู้ให้บริการทุกเจ้า', + 'th': [ + 'GeoJSONParser.parseFeatures แปลง GeoJSON ในโค้ดเป็นฟีเจอร์ พิกัดเรียงลองจิจูดก่อนละติจูด และวงแหวนของโพลีกอนต้องปิด', + 'GeoJSONLayerState กำหนดสีเติมและเส้นขอบ ส่วน GeoJSONLayer วาดโพลีกอนพร้อมรูจากฟีเจอร์ที่เก็บด้วย useMemo', ], - hi: [ - 'GeoJSON फ़ीचर लोड करें और उन्हें एक ही प्रोवाइडर-निरपेक्ष लेयर स्टाइल से रेंडर करें।', - 'फ़ीचर React स्टेट में रहते हैं, और layer एक GeoJSONLayerState है जो useMemo से एक बार बनता है।', - 'दोनों को को देने पर हर फ़ीचर एक ही साझा स्टाइल से खिंचता है, जो किसी भी प्रोवाइडर पर एक जैसा दिखता है।', + 'hi': [ + 'GeoJSONParser.parseFeatures कोड के GeoJSON को फ़ीचर में बदलता है। निर्देशांक में देशांतर पहले आता है और पॉलीगॉन के रिंग बंद होने चाहिए।', + 'GeoJSONLayerState भराव और रेखा तय करता है। GeoJSONLayer छेद वाला पॉलीगॉन बनाता है।', ], }, }; diff --git a/examples/basic/src/samples/documentation/pages/geojson-layer.ts b/examples/basic/src/samples/documentation/pages/geojson-layer.ts index 750bb2c..a0f6cea 100644 --- a/examples/basic/src/samples/documentation/pages/geojson-layer.ts +++ b/examples/basic/src/samples/documentation/pages/geojson-layer.ts @@ -5,46 +5,51 @@ const doc: SamplePageDoc = { {selected && ( - +
{JSON.stringify(selected.properties, null, 2)}
)} `, - state: `const [features, setFeatures] = useState([]); -const [selected, setSelected] = useState(null); + state: `const features = useMemo(() => GeoJSONParser.parseFeatures(JSON.stringify({ + type: 'FeatureCollection', + features: [{ type: 'Feature', properties: { name: 'Sample railway' }, + geometry: { type: 'LineString', coordinates: [[139.75, 35.68], [139.77, 35.69], [139.79, 35.68]] }, + }], +})), []); +const [selected, setSelected] = useState<{ + position: GeoPoint; properties: Record; +} | null>(null); const layerState = useMemo(() => new GeoJSONLayerState({ strokeColor: colorArgb(200, 250, 36, 29), strokeWidth: 6, -}), []);`, + onClick: (feature, position) => setSelected({ position: createGeoPoint(position), properties: feature.properties ?? {} }), +}), []); +const handleMapClick = (point: GeoPoint) => { + if (!layerState.processClick(point, 10, mapViewState.cameraPosition.zoom)) setSelected(null); +};`, explanation: { - en: [ - "Process clicks against GeoJSON features and show the selected feature's properties at the geographic click position.", - 'Both the features and the selected feature live in React state, and layerState is a GeoJSONLayerState holding the railway data.', - 'handleMapClick resolves which feature was hit and stores it, then InfoBubble anchors a PropertyTable of its properties at the clicked coordinate.', + 'en': [ + 'The inline GeoJSON contains a sample railway line, so the snippet needs no data server.', + 'handleMapClick calls processClick with the current zoom. A hit invokes layerState.onClick, which stores the feature properties and click position for the InfoBubble; a miss clears the selection.', ], - ja: [ - 'GeoJSON Feature へのクリックを判定し、選択した Feature の属性をクリック地点に表示します。', - 'features と選択中の Feature はどちらも React の state に保持し、layerState は鉄道データ用の GeoJSONLayerState です。', - 'handleMapClick がどの Feature に当たったかを判定して保存し、InfoBubble がその属性の PropertyTable をクリック座標に固定します。', + 'ja': [ + '外部データサーバーなしで試せるよう、説明用の鉄道路線を GeoJSON としてコード内に用意しています。', + 'handleMapClick は現在のズームを渡して processClick を呼びます。ヒット時に layerState.onClick が属性とクリック地点を保存して InfoBubble へ表示し、何もない場所をクリックすると選択を解除します。', ], 'es-419': [ - 'Procesa clics sobre elementos GeoJSON y muestra las propiedades del elemento seleccionado en la posición geográfica del clic.', - 'Tanto los features como el elemento seleccionado viven en el estado de React, y layerState es un GeoJSONLayerState con los datos ferroviarios.', - 'handleMapClick determina qué elemento se tocó y lo almacena, luego InfoBubble ancla un PropertyTable de sus propiedades en la coordenada tocada.', + 'El GeoJSON incluido contiene una línea de ferrocarril de ejemplo y no necesita servidor de datos.', + 'handleMapClick llama a processClick con el zoom actual. onClick guarda las propiedades para el InfoBubble cuando hay un acierto; de lo contrario borra la selección.', ], - de: [ - 'Klicks gegen GeoJSON-Features auswerten und die Eigenschaften des getroffenen Features an der geografischen Klickposition zeigen.', - 'Sowohl die Features als auch das ausgewählte Feature liegen im React-State, und layerState ist ein GeoJSONLayerState mit den Bahndaten.', - 'handleMapClick ermittelt das getroffene Feature und merkt es sich, dann verankert InfoBubble eine PropertyTable seiner Eigenschaften an der angeklickten Koordinate.', + 'de': [ + 'Das eingebettete GeoJSON enthält eine Beispielbahnlinie und benötigt keinen Datenserver.', + 'handleMapClick ruft processClick mit dem aktuellen Zoom auf. Bei einem Treffer speichert onClick die Attribute für die InfoBubble; andernfalls wird die Auswahl aufgehoben.', ], - th: [ - 'ประมวลผลการคลิกกับฟีเจอร์ GeoJSON แล้วแสดงคุณสมบัติของฟีเจอร์ที่เลือกไว้ ณ ตำแหน่งภูมิศาสตร์ที่คลิก', - 'ทั้งฟีเจอร์และฟีเจอร์ที่เลือกอยู่ในสถานะของ React ส่วน layerState คือ GeoJSONLayerState ที่ถือข้อมูลเส้นทางรถไฟ', - 'handleMapClick หาว่าโดนฟีเจอร์ใดแล้วเก็บไว้ จากนั้น InfoBubble จะตรึง PropertyTable ของคุณสมบัตินั้นไว้ที่พิกัดที่คลิก', + 'th': [ + 'GeoJSON ในโค้ดมีเส้นทางรถไฟตัวอย่าง จึงไม่ต้องใช้เซิร์ฟเวอร์ข้อมูล', + 'handleMapClick เรียก processClick ด้วยระดับซูมปัจจุบัน เมื่อพบฟีเจอร์ onClick จะเก็บคุณสมบัติให้ InfoBubble หากไม่พบจะล้างการเลือก', ], - hi: [ - 'क्लिक को GeoJSON फ़ीचर के विरुद्ध जाँचें और चुने गए फ़ीचर की प्रॉपर्टी उसी भौगोलिक जगह पर दिखाएँ।', - 'फ़ीचर और चुना गया फ़ीचर दोनों React स्टेट में रहते हैं, और layerState एक GeoJSONLayerState है जिसमें रेलवे का डेटा है।', - 'handleMapClick तय करता है कि कौन-सा फ़ीचर लगा और उसे रख लेता है; फिर InfoBubble उसकी प्रॉपर्टी की PropertyTable क्लिक किए गए निर्देशांक पर टिका देता है।', + 'hi': [ + 'कोड के GeoJSON में उदाहरण रेल लाइन है, इसलिए डेटा सर्वर की ज़रूरत नहीं।', + 'handleMapClick वर्तमान ज़ूम से processClick बुलाता है। फ़ीचर मिलने पर onClick गुणों को InfoBubble के लिए रखता है; न मिलने पर चयन हटता है।', ], }, }; diff --git a/examples/basic/src/samples/documentation/pages/ground-image.ts b/examples/basic/src/samples/documentation/pages/ground-image.ts index 953b096..914beeb 100644 --- a/examples/basic/src/samples/documentation/pages/ground-image.ts +++ b/examples/basic/src/samples/documentation/pages/ground-image.ts @@ -1,11 +1,32 @@ import type { SamplePageDoc } from '../types'; const doc: SamplePageDoc = { + camera: { latitude: -26.479235, longitude: 31.306239, zoom: 15 }, code: ` `, - state: `const [groundImageState] = useState(() => createGroundImageState({ + state: `// Generate a PNG: MapLibre's image decoder cannot read SVG sources. +// In your app this can instead be a URL to a PNG or JPEG image. +const imageUrl = useMemo(() => { + const canvas = document.createElement('canvas'); + canvas.width = 400; + canvas.height = 300; + const context = canvas.getContext('2d')!; + context.fillStyle = '#bfdbfe'; + context.fillRect(0, 0, 400, 300); + context.fillStyle = '#2563eb'; + context.fillRect(196, 0, 8, 300); + context.fillRect(0, 146, 400, 8); + context.font = '24px sans-serif'; + context.fillText('Ground image', 20, 40); + return canvas.toDataURL('image/png'); +}, []); +const initialBounds = createGeoRectBounds({ + southWest: createGeoPoint({ latitude: -26.4849, longitude: 31.2996 }), + northEast: createGeoPoint({ latitude: -26.4736, longitude: 31.3129 }), +}); +const [groundImageState] = useState(() => createGroundImageState({ id: 'historic-map', imageUrl, bounds: initialBounds, opacity: 0.7, })); @@ -16,11 +37,19 @@ const moveSouthWest = (markerState: MarkerState) => { }); }; -const southWestMarker = createMarkerState({ +const moveNorthEast = (markerState: MarkerState) => { + groundImageState.bounds = createGeoRectBounds({ + southWest: groundImageState.bounds.southWest, + northEast: markerState.position, + }); +}; +const cornerMarkers = useMemo(() => [createMarkerState({ id: 'south-west', position: groundImageState.bounds.southWest!, draggable: true, onDrag: moveSouthWest, onDragEnd: moveSouthWest, -}); -const cornerMarkers = [southWestMarker, northEastMarker];`, +}), createMarkerState({ + id: 'north-east', position: groundImageState.bounds.northEast!, + draggable: true, onDrag: moveNorthEast, onDragEnd: moveNorthEast, +})], [groundImageState]);`, explanation: { en: [ 'Keep one GroundImageState instance and update its bounds from the draggable corner markers.', diff --git a/examples/basic/src/samples/documentation/pages/heatmap-layer.ts b/examples/basic/src/samples/documentation/pages/heatmap-layer.ts index 31533c2..21e9ec3 100644 --- a/examples/basic/src/samples/documentation/pages/heatmap-layer.ts +++ b/examples/basic/src/samples/documentation/pages/heatmap-layer.ts @@ -6,47 +6,40 @@ const doc: SamplePageDoc = { `, - state: `const [heatmapPoints, setHeatmapPoints] = useState([]); -useEffect(() => { - fetch('/postoffice/postoffices.json') - .then(response => response.json() as Promise<[number, number][]>) - .then(data => setHeatmapPoints(data.map(([latitude, longitude], index) => - new HeatmapPointState({ - id: \`post-office-\${index}\`, - position: createGeoPoint({ latitude, longitude }), - }), - ))); -}, []);`, + state: `// Inline points work without the example app's public data files. +const heatmapPoints = useMemo(() => Array.from({ length: 100 }, (_, index) => + new HeatmapPointState({ + id: \`point-\${index}\`, + position: createGeoPoint({ + latitude: 35.68 + Math.sin(index * 2.4) * (index % 10) * 0.001, + longitude: 139.77 + Math.cos(index * 2.4) * (index % 10) * 0.001, + }), + }), +), []);`, explanation: { - en: [ - 'Compose weighted geographic points inside the heatmap extension overlay.', - 'A useEffect fetches postoffices.json and maps each coordinate into a HeatmapPointState kept in React state.', - 'The inside composes the whole set at once, and the extension renders the density map on any provider.', + 'en': [ + 'HeatmapOverlay renders the density of geographic points.', + 'useMemo creates 100 sample HeatmapPointState objects without an external file. HeatmapPoints submits them together to the overlay.', ], - ja: [ - '重みを持つ地理座標の点群を、ヒートマップ拡張オーバーレイ内で一括構成します。', - 'useEffect が postoffices.json を取得し、各座標を HeatmapPointState へ変換して React の state に保持します。', - ' 内の が点群をまとめて構成し、拡張がどのプロバイダーでも密度マップを描画します。', + 'ja': [ + 'HeatmapOverlay は地理座標の点群を密度マップとして描画します。', + 'この短い例は外部ファイルを使わず、useMemo で説明用の HeatmapPointState を100件生成します。HeatmapPoints が点群をまとめてオーバーレイへ渡します。', ], 'es-419': [ - 'Compone puntos geográficos ponderados dentro de la superposición de la extensión de mapa de calor.', - 'Un useEffect obtiene postoffices.json y convierte cada coordenada en un HeatmapPointState guardado en el estado de React.', - 'El dentro de compone todo el conjunto de una vez, y la extensión dibuja el mapa de densidad en cualquier proveedor.', + 'HeatmapOverlay dibuja la densidad de puntos geográficos.', + 'useMemo crea 100 objetos HeatmapPointState de ejemplo sin archivos externos. HeatmapPoints los envía juntos a la capa.', ], - de: [ - 'Gewichtete geografische Punkte im Heatmap-Erweiterungs-Overlay zusammensetzen.', - 'Ein useEffect holt postoffices.json und bildet jede Koordinate auf einen HeatmapPointState im React-State ab.', - 'Das innerhalb von setzt den ganzen Satz auf einmal zusammen, und die Erweiterung zeichnet die Dichtekarte bei jedem Anbieter.', + 'de': [ + 'HeatmapOverlay zeichnet die Dichte geografischer Punkte.', + 'useMemo erzeugt 100 HeatmapPointState-Beispielpunkte ohne externe Datei. HeatmapPoints übergibt sie gemeinsam an das Overlay.', ], - th: [ - 'ประกอบจุดภูมิศาสตร์แบบถ่วงน้ำหนักไว้ในโอเวอร์เลย์ส่วนขยายฮีตแมป', - 'useEffect ดึง postoffices.json แล้วแปลงแต่ละพิกัดเป็น HeatmapPointState ที่เก็บไว้ในสถานะของ React', - ' ภายใน ประกอบชุดข้อมูลทั้งหมดในคราวเดียว และส่วนขยายจะเรนเดอร์แผนที่ความหนาแน่นได้บนผู้ให้บริการทุกเจ้า', + 'th': [ + 'HeatmapOverlay แสดงความหนาแน่นของจุดภูมิศาสตร์', + 'useMemo สร้าง HeatmapPointState ตัวอย่าง 100 จุดโดยไม่ใช้ไฟล์ภายนอก และ HeatmapPoints ส่งจุดทั้งหมดให้โอเวอร์เลย์พร้อมกัน', ], - hi: [ - 'भारित भौगोलिक बिंदुओं को हीटमैप एक्सटेंशन के ओवरले के भीतर जोड़ें।', - 'एक useEffect postoffices.json लाता है और हर निर्देशांक को React स्टेट में रखे HeatmapPointState में बदल देता है।', - ' के भीतर का पूरा सेट एक साथ जोड़ता है, और एक्सटेंशन घनत्व का मैप किसी भी प्रोवाइडर पर रेंडर कर देता है।', + 'hi': [ + 'HeatmapOverlay भौगोलिक बिंदुओं का घनत्व दिखाता है।', + 'useMemo बाहरी फ़ाइल के बिना 100 उदाहरण HeatmapPointState बनाता है। HeatmapPoints उन्हें एक साथ ओवरले को देता है।', ], }, }; diff --git a/examples/basic/src/samples/documentation/pages/info-bubble-multiple.ts b/examples/basic/src/samples/documentation/pages/info-bubble-multiple.ts index 92716ac..3cfabef 100644 --- a/examples/basic/src/samples/documentation/pages/info-bubble-multiple.ts +++ b/examples/basic/src/samples/documentation/pages/info-bubble-multiple.ts @@ -9,12 +9,18 @@ const doc: SamplePageDoc = { ))} `, - state: `const [selectedMarkers, setSelectedMarkers] = useState([]); + state: `const positions = useMemo(() => [ + createGeoPoint({ latitude: 35.6812, longitude: 139.7671 }), + createGeoPoint({ latitude: 35.6862, longitude: 139.7771 }), +], []); +const [selectedMarkers, setSelectedMarkers] = useState([]); const markers = useMemo(() => positions.map((position, index) => createMarkerState({ id: \`marker-\${index}\`, position, - onClick: markerState => setSelectedMarkers(current => [...current, markerState]), + extra: \`Place \${index + 1}\`, + onClick: markerState => setSelectedMarkers(current => + current.some(item => item.id === markerState.id) ? current : [...current, markerState]), })), [positions]); -const activeMarker = selectedMarkers.at(-1) ?? null;`, +`, explanation: { en: [ 'Keep multiple markers selected and render one independently positioned bubble for each.', diff --git a/examples/basic/src/samples/documentation/pages/info-bubble-rich.ts b/examples/basic/src/samples/documentation/pages/info-bubble-rich.ts index f976481..fa92a53 100644 --- a/examples/basic/src/samples/documentation/pages/info-bubble-rich.ts +++ b/examples/basic/src/samples/documentation/pages/info-bubble-rich.ts @@ -3,18 +3,19 @@ import type { SamplePageDoc } from '../types'; const doc: SamplePageDoc = { code: ` - + {selected &&

Place details

-
+
}
`, - state: `const position = createGeoPoint({ latitude: 35.6812, longitude: 139.7671 }); + state: `const position = useMemo(() => createGeoPoint({ latitude: 35.6812, longitude: 139.7671 }), []); const [selected, setSelected] = useState(false); const marker = useMemo(() => createMarkerState({ id: 'place', position, onClick: () => setSelected(true), -}), [position]);`, +}), [position]); +const openDetails = () => window.alert('Tokyo Station, Tokyo, Japan');`, explanation: { en: [ 'Render interactive React elements inside a marker-anchored bubble, including headings and buttons.', diff --git a/examples/basic/src/samples/documentation/pages/info-bubble-simple.ts b/examples/basic/src/samples/documentation/pages/info-bubble-simple.ts index e595768..0112096 100644 --- a/examples/basic/src/samples/documentation/pages/info-bubble-simple.ts +++ b/examples/basic/src/samples/documentation/pages/info-bubble-simple.ts @@ -7,7 +7,7 @@ const doc: SamplePageDoc = { Simple text content )} `, - state: `const position = createGeoPoint({ latitude: 35.6812, longitude: 139.7671 }); + state: `const position = useMemo(() => createGeoPoint({ latitude: 35.6812, longitude: 139.7671 }), []); const [selected, setSelected] = useState(false); const marker = useMemo(() => createMarkerState({ id: 'place', position, onClick: () => setSelected(true), diff --git a/examples/basic/src/samples/documentation/pages/info-bubble-styled.ts b/examples/basic/src/samples/documentation/pages/info-bubble-styled.ts index 5b555fc..22b43fa 100644 --- a/examples/basic/src/samples/documentation/pages/info-bubble-styled.ts +++ b/examples/basic/src/samples/documentation/pages/info-bubble-styled.ts @@ -1,7 +1,13 @@ import type { SamplePageDoc } from '../types'; const doc: SamplePageDoc = { - code: ` + code: ` + + + + setStrokeWidth(Number(e.target.value))} /> + setMarkerScale(Number(e.target.value))} /> + {selected && ( - +
{JSON.stringify(selected.properties, null, 2)}
)}
`, - state: `const [features, setFeatures] = useState([]); -const [selected, setSelected] = useState(null); -const layerState = useMemo(() => new KMLLayerState({ /* fallback style */ }), []); - -// Fetch the document (here: public/sample.kml) and parse it into features. -useEffect(() => { - fetch('/sample.kml') - .then(response => response.text()) - .then(text => setFeatures(KMLParser.parse(text))); -}, []);`, + state: `const features = useMemo(() => KMLParser.parse( + '' + + 'Sample route' + + '139.75,35.68 139.77,35.69 139.79,35.68' + + '', +), []); +const [selected, setSelected] = useState<{ + position: GeoPoint; properties: Record; +} | null>(null); +const layerState = useMemo(() => new KMLLayerState({ + onClick: (feature, position) => setSelected({ position: createGeoPoint(position), properties: feature.properties ?? {} }), +}), []); +const handleMapClick = (point: GeoPoint) => { + if (!layerState.processClick(point, 10, mapViewState.cameraPosition.zoom)) setSelected(null); +};`, explanation: { - en: [ - 'Parse a KML document with KMLParser and render its placemarks — styled polygons, lines, and points — as a tiled overlay.', - 'KMLParser.parse takes the KML text, so fetch the document yourself first; KMLLoader.load takes a URL instead (anything fetch accepts — a same-origin path such as /sample.kml or an absolute https URL), follows references and unpacks KMZ archives. On the web that fetch is subject to CORS, so a cross-origin document loads only when its server sends Access-Control-Allow-Origin — otherwise serve it from your own origin or inject a proxying fetch through the KMLLoader constructor.', - 'Both the features and the selected feature live in React state, and layerState is a KMLLayerState whose style is the fallback used when a placemark carries no KML