Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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 で実際に踏んだ)。
Expand Down
11 changes: 11 additions & 0 deletions examples/basic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions examples/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
62 changes: 62 additions & 0 deletions examples/basic/scripts/check-snippets.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
166 changes: 166 additions & 0 deletions examples/basic/scripts/snippet-interactions.mjs
Original file line number Diff line number Diff line change
@@ -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.');
}
50 changes: 43 additions & 7 deletions examples/basic/src/samples/documentation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -74,6 +79,11 @@ const DOCUMENTATION: Record<string, SamplePageDoc> = {
'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 = {
Expand Down Expand Up @@ -129,6 +139,7 @@ const SECTION_COMMENTS: Record<SupportedLanguage, {

function adaptProviderView(code: string, provider: ProviderCode): string {
return code
.split('ProviderDesign').join(provider.designClass)
.split('<MapViewContainer').join(`<${provider.component}${provider.openingProps}`)
.split('</MapViewContainer>').join(`</${provider.component}>`);
}
Expand All @@ -149,6 +160,8 @@ const IMPORT_DEFINITIONS: readonly ImportDefinition[] = [
values: [
'ColorDefaultIcon',
'DefaultMarkerIcon',
'ImageIcon',
'MapUISettings',
'MarkerAnimation',
'MarkerTilingOptions',
'PolygonManager',
Expand Down Expand Up @@ -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'],
Expand All @@ -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 {
Expand All @@ -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');
}

Expand All @@ -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 <div style={{ height: 500, position: 'relative', display: 'flex', flexDirection: 'column' }}>\n${adaptProviderView(documentation.code, provider).split('\n').map(line => ` ${line}`).join('\n')}\n </div>\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,
Expand Down
Loading
Loading