Design Archive
中EN

From one 3D tilted map to an offline tool

The perspective of a 3D tilted map in the design cannot be handed to the front end, because the map SDK's field of view is fixed. I turned a one-off request for tilted map images into a single-file offline tool, and reversed the direction, measuring the front-end map's perspective first and then making the design follow it.

L.14 / 14
CATEGORY
Tool
YEAR
2026
STATUS
Note
TOOLS
svg / geojson / mercator

entryWrite-up

The main visual of a data visualization screen is a map with a 3D tilt: the regions have thickness, the sides carry a gradient, and the top is covered with a ground texture; the design is made in a vector design tool, and the front end is built with a WebGL map SDK. The starting point was only a JSON file, exported by the 3D map plugin of a design tool, and I wanted to know whether it could be opened in the design tool directly. Once the file was taken apart, there were four problems: the file held only flat outlines plus a set of 3D rendering parameters (depth, camera position and a few point lights), the plugin computed the 3D effect live at run time, and there was no ready-made 3D vector to take; a vector design tool can squash a shape to fake a tilt, but cannot make near things larger and far things smaller; the front-end map SDK’s field of view is fixed, so however good the perspective looks in the design, the front end may not be able to reproduce it; and the sides have to take their own gradient in the design tool, so the thickness must be a separate, clean layer.

The first request was only to rotate the map 30° around the X axis, and a Python script multiplied the Y coordinates by cos30°, an orthographic squash. The result was too flat, with no sense of space, so it switched to perspective. Perspective can be thought of as placing the map in front of a real camera: the closer a part is to the lens, the larger it gets; how far the camera is from the map became a parameter that controls how strong the effect is.

The same 45° tilt: on the left, orthographic, with the same scale everywhere (×1.00); on the right, perspective, with the bottom edge enlarged 1.42 times and the top edge shrunk to 0.77.

Running the script for every change of angle was too slow, so it became a single-file HTML tool, required to import SVG, adjust three-axis rotation, projection mode and perspective strength, show a live preview that can be switched off so large files do not lag, and export SVG. The first version also supported Bézier curves and arcs, folded nested coordinate transformation attributes (that is, transform) into the coordinates, kept inherited styles, and exported plain paths with no transform, with every step running locally in the browser. Once the same operation comes up a third time, it is time to consider making it a tool; here, the switch from script to tool came the third time the parameters were changed and the images re-exported.

The pipeline: every step runs locally in the browser.

The design’s parameters had to be handed to the front end: the tilt angle maps to the front end’s tilt (pitch) and the Z rotation to heading (rotation), and both can be copied as they are; the Y rotation has no matching parameter in the map; perspective strength cannot be passed on, because the front-end SDK’s field of view is fixed and the strength of perspective depends only on the zoom level and the container height. So the direction was reversed: rather than making the front end follow the design, first measure the front-end map’s real perspective, then make the design follow it. A calibration script on the front-end page measures the ratio R between the pixel widths of the same ground distance at the bottom and top of the view, then converts R back into the tool’s camera distance; the closer R is to 1, the weaker the perspective, and the farther away the calculated camera. The method was verified on a simulated map first, with the camera distance set to 1.2 and the map heading turned by 37°: the script’s reverse calculation gave exactly 1.2000 and restored the heading automatically, so the check passed. Aligning the design with the front end depends on a shared set of parameters and a calibration method that can be run, not on making renderings look alike. The parameters were saved as a parameter preset JSON, and the calibration method was written into a handoff spec.

Exporting SVG by hand and importing it again was too roundabout, so I wanted to use administrative-region GeoJSON (a map data format with longitude and latitude) directly. For this, three data sources were assessed: importing files by hand, a built-in offline data pack, and calling the boundary data that comes with the front-end map SDK; a test against the API of a public data site showed that, apart from a few popular datasets, cross-site requests were almost all blocked by hotlink protection, returning 403. Because the need was only for the design and the front end to use the same data, the other two options were not worth their cost and licensing risk, so only manual import was built for now. Import switched to the Mercator projection that most web maps use underneath, so the flat shapes already match the front end; the tool works out the center point and zoom level the front end needs, and supports the coordinate offset conversions common in China.

The thickness has to be a separate layer in the design tool, and the shape of the thickness layer decides whether the gradient looks good. Of the three approaches compared, the choice was a “visible side band”: first merge all regions into one outer outline, so that no side walls rise between neighboring regions; then generate the side walls and cull the ones facing away from the camera; finally use polygon boolean operations to subtract the top face, leaving only the ring that can actually be seen. For this an open-source polygon clipping library is embedded, and the tool is still a single file that runs offline; when the thickness needs a light-to-dark transition along its depth, it can be cut into several slices by depth, one color per slice.

The thickness cut into 5 slices by depth, one color each.

The satellite image first cropped with a script was only a flat picture and could not tilt with the map once placed in the design tool, so the question became whether generating the texture and mapping it could both be done inside the tool. The map’s top face is a plane, and a plane’s distortion under perspective is one fixed mapping (mathematically, a homography), so every output pixel can be traced exactly back to a longitude and latitude on the map and its color taken from the satellite image. The generated texture fits the vector outline exactly, and the data source is a public-domain global satellite image built into the tool. One more requirement was no boundary lines in the texture: the texture itself contains only image pixels and the boundary lines come from the top face’s stroke, so the stroke became a separate switch.

As features piled up, the right-hand panel became a long list that took two and a half screens of scrolling, and the problem was not visual but one of information architecture. The changes, in order of priority: the right side is split into three tabs, “View / Appearance / Delivery”; the export entry points are gathered into one dialog; selecting a layer switches the right side to that layer’s properties, as common design tools do; scattered hints are collected in the status bar; the canvas gained a front-end viewfinder; and parameter presets can be saved.

The tool: layers on the left, the canvas in the middle, the Appearance tab on the right.
The Delivery panel generates front-end code live; the dashed frame is what the front-end container actually sees at this zoom level.

The earliest perspective script drew one huge rectangle over the real map. Suspecting the renderer first and then checking layer by layer showed that the cause was the original paths using the horizontal and vertical line commands that carry only one coordinate (that is, the H and V commands), while the script paired every number up as x and y, so from a certain point on the whole path was shifted. After the fix it turned out that the canvas sizes of two files delivered earlier had also been miscalculated by this bug, and both were regenerated; when the tool was built properly, the parser supported every path command, including the compact forms that minifiers often produce. Every step has to run on real data and be looked at, not just pass its tests.

The same problem reproduced with sample data: on the left, every number paired up, the wrong parse; on the right, H / V recognized as carrying one coordinate, the right parse.

An assertion in one unit test run had actually failed, while “all passed” had been reported at the time; this only came out during a review. On checking, the test itself was wrong and the code was right. Since then, test results are read from the raw output first, not just the conclusion.

When one map was split into several pieces and imported separately, the pieces ended up stacked on top of each other. The reason was that each GeoJSON layer was scaled on its own to “800px on the long side”, and the longitude and latitude information was lost at that step. The fix was to have all geographic layers share one Mercator coordinate system, using the same origin and the same scale.

When one region was imported first and a larger surrounding region added after it, the main subject clearly flattened and shrank; this is something I noticed myself. The reason was that the camera’s center and distance were computed from the combined extent of all layers, so with a larger extent the subject took up only a small area in the middle of the view and the perspective naturally weakened. The fix was to introduce a “camera anchor layer”: the camera is computed around the anchor layer only, and adding other layers does not affect it; thickness was changed to lock a real-world distance, converted automatically when the anchor or the size changes.

The same region A: on the left, with A as the anchor, the near / far scale ratio is 1.84; on the right, with the larger region B as the anchor, A gets smaller and the near / far ratio drops to 1.63. Before the fix, adding surrounding layers looked like the right side.

The texture turning black in the preview while the export was fine took two rounds to fix. The first round reproduced in testing that whenever an input field had focus, the browser could keep postponing the asynchronous image encoding, so the texture took a long time to appear; switching to synchronous encoding solved it. In the second round the texture went all black again, but it could not be reproduced in the development environment; the inference was that SVG image clipping on complex layers renders wrongly in some browsers, and the preview changed to “canvas drawing + outline mask”, avoiding that rendering path entirely, with the export format unchanged. This fix was not verified on the browser where the problem happened, so it is recorded as “most likely solved”, not “confirmed solved”.

For ease of implementation, the first layer list drew lower items on top, the opposite of mainstream design tools, and this was mentioned only in a line of help text; I found it counter-intuitive and asked for it to change. After the change, the list order matches design tools, supports drag-to-reorder, and has an added “auto-sort by distance” button, specifically for far side walls covering near ones when pieces are extruded separately.

When screenshots were being taken for this record, the viewfinder showed the zoom level as NaN. The cause was that when the camera anchor was introduced, part of the data moved elsewhere while the viewfinder still read the old location. It has been fixed in the tool; after every refactor, every entry point has to be walked through in full.

Starting from a configuration file that would not open, after more than twenty rounds of iteration, the deliverable is a single HTML file of about 2.3 MB that opens with a double-click in the browser and runs offline, covering everything from importing data to handing off to the front end. Every version ran on real data and was checked by me. The screenshots here all come from real runs of the tool; for data licensing and map compliance reasons, the demo uses a public-domain Southeast Asian sample region, not the project’s real data.

The export dialog: layer SVGs, texture PNG and parameter preset JSON, packed automatically when there are several files.

processBefore & after

Parameter mapping (design → front-end map SDK)

X rotation
tilt; 0 is straight down, the usual limit is about 70°, check against the SDK
Z rotation
heading; the sign differs by SDK, test with one line of code first
Y rotation
no matching parameter; the map cannot roll, so the design must use 0
Camera distance
no matching parameter; the SDK field of view is fixed, calibrate in reverse
Center of the camera anchor layer
center, computed by the tool from longitude and latitude
Container size + anchor layer extent
zoom; the tool gives an estimate, fine-tune on the device
Frozen together
container size, zoom, center, tilt, heading

Calibrating perspective strength

text
1. Front-end sets up the map as in the table above
2. Run the calibration script the tool generates in the page console;
   it measures the ratio R between the pixel widths of the same
   ground distance at the bottom and top of the view
3. Enter R back in the tool, and the camera distance is converted:

   camera distance factor = sin(tilt angle) × (R + 1) / (2 × (R − 1))

Tool features

Import
SVG: every path command, class / style styling, use references, nested transforms · GeoJSON: map selection, search, coordinate offset conversion
View
three-axis rotation · perspective / orthographic · camera distance · view presets · live near / far scale readouts
Appearance
extruded thickness: downward / upward, 1–8 slices, side gradient · built-in satellite texture or a custom image: color adjustment, tinting · boundary line switch
Layers
per-layer style override · opt in or out of effects · drag to reorder · auto-sort by distance · camera anchor
Delivery
front-end parameter code · viewfinder · perspective calibration script · parameter preset JSON
Export
layered SVG (thickness / texture / top) · texture PNG · preset JSON · packed automatically when there are several files

Layer structure exported to the design tool

Thickness
the sides; with one slice it carries a top-to-bottom gradient whose fill can be replaced, with several slices it becomes "Thickness-1 … Thickness-N"
Texture
the ground imagery (bitmap), clipped to the top face outline
Top
one vector per region, with the region name as id
Coordinates
baked into the paths with no transform wrapper; all layers share one projection origin, so they line up when stacked

resultTakeaways

Finding
Perspective strength cannot be passed to front-end (the map SDK's field of view is fixed), so it is calibrated in reverse. Measure the ratio R between the pixel widths of the same ground distance at the bottom and top of the view, and convert R back into the tool's camera distance; on a simulated map set to a camera distance of 1.2, the reverse calculation gave exactly 1.2000.
Parameters
X rotation → tilt · Z rotation → heading · Y rotation must be 0 · camera distance factor = sin(tilt angle) × (R + 1) / (2 × (R − 1)) · container size, zoom, center, tilt and heading frozen together
Open
The second fix for the texture turning black in the preview was not verified on the browser where it happened, so it can only be recorded as "most likely solved"; the zoom the tool gives is an estimate and needs fine-tuning on the real device.

logChangelog

2026-10
First entry

Related experiments

← LabL.14 / 14
1 / 1

100%