Travel Album and Map Feature Implementation Notes
This document is not an operations manual for "just adding a new travel post." It is an implementation reference for anyone who will continue maintaining this feature set.
The goal is one thing:
- Clearly explain why the Travel section is designed this way, how it currently works, and which lines to follow when maintaining it going forward.
If you only want to add content, start here:
If you encounter AMap marker drift (points shifted to the other side of the road), start here:
What This Feature Set Actually Solved
The current Travel section is not just "image layout." It is a complete pipeline that solves:
- Stably turning travel content into
docs/Travel/*.mdxpages. - Unifying image references into maintainable relative paths instead of scattered full OSS URLs.
- Automatically extracting EXIF, capture time, device, lens, and GPS from JPEGs.
- Running AMap reverse geocoding on GPS-tagged photos for human-readable place names.
- Displaying a "photo capture map" on each article page.
- Displaying a "My Footprints" overview, footprint map, and travel article cards on the Travel category homepage.
- Making it so that adding new articles does not require re-hand-writing map, card, and metadata logic each time.
Design Principles
Five fixed principles underpin this implementation:
1. The Article File Owns the Narrative Structure
docs/Travel/*.mdx determines:
- Article title
- Paragraph order
- Image groups
- The order of each image group
In other words, the article file is the single source of truth for the narrative structure.
2. Metadata and Map Data Are Generated Artifacts
The frontend should not re-read EXIF or re-request reverse geocoding on every run, nor should it infer map structures on the fly.
So there are two generated artifacts:
src/data/travelPhotoMetadata.generated.jsonsrc/data/travelMap.generated.json
The first handles "per-image information." The second handles "article-level and place-level map information."
3. Raw GPS Is Preserved in the Data Layer
Coordinates in photo EXIF remain as raw GPS and are not forcefully converted to AMap-renderable coordinates during generation.
The reasons:
- Raw data stays traceable
- Reverse geocoding and other map SDKs can still reuse raw GPS
- Coordinate conversion is concentrated in the frontend map rendering layer, keeping responsibilities clearer
4. Map Loading Mode Depends on Page Role
The article page map defaults to collapsed and expands on click. The Travel category homepage map is one of the page's core pieces of information, so it renders in embedded mode directly.
This is not contradictory -- the page roles are different:
- On article pages, the map is supplementary information after the body; it should not steal attention from the text.
- On the Travel homepage, the map and "My Footprints" overview are above-the-fold main content; it can load directly.
- Both modes reuse the same
TravelOverviewMap/TravelMapunderlying capability; only the initialization timing differs.
5. The Generation Pipeline Prioritizes Maintainability Over "One-Shot Speed"
So the current approach is not a single step from "user's verbal description" to final MDX. Instead, it is a two-stage process:
- First organize a source draft
- Then generate the final article via script
This is more stable than hand-writing final MDX in one shot, because when you later revise copy, add or remove images, or rearrange groups, you can reuse the same pipeline.
Overall Layering
The current implementation splits into four layers.
| Layer | Primary Responsibility | Key Files |
|---|---|---|
| Content Layer | Stores the final Travel articles | docs/Travel/*.mdx |
| Generation Layer | Generates articles, photo metadata, and map data | scripts/create-travel-article.mjs, scripts/generate-travel-photo-metadata.mjs, scripts/generate-travel-map-data.mjs |
| Data Layer | Provides stable JSON to the frontend | src/data/travelPhotoMetadata.generated.json, src/data/travelMap.generated.json |
| Display Layer | Renders albums, lightbox, article maps, Travel homepage, and overview map | src/components/TravelGallery/*, src/components/TravelMap/*, src/components/TravelHome/* |
End-to-End Data Flow
The complete pipeline runs like this:
- You provide a location, OSS image links, and a short description -- or directly provide a Markdown source draft.
- AI first organizes the source draft, or you prepare it manually.
scripts/create-travel-article.mjsconverts the source draft intodocs/Travel/<slug>.mdx.scripts/generate-travel-photo-metadata.mjsscans all image references in Travel articles, downloads JPEGs, extracts EXIF and GPS, and calls AMap reverse geocoding when GPS is present.scripts/generate-travel-map-data.mjsreads theTravelGalleryorder and photo metadata from the article to generate the structures needed by the article page map and category page map.TravelGalleryfills in display information for each image from the photo metadata JSON.TravelHomereads article, place, and photo data from the map JSON and renders the Travel category homepage.TravelStoryMapandTravelOverviewMapread points from the map JSON and render AMap maps on the frontend.
Content Layer: Why the Final Article Is Still MDX
The final output lands in docs/Travel/*.mdx instead of only keeping a database or JSON. Three reasons:
- Docusaurus natively centers on document files.
- The relationship between article body and image groups is inherently document structure.
- MDX can directly reuse
TravelGalleryandTravelStoryMapcomponents without an extra translation layer.
A current Travel article contains at minimum:
- Front matter
TravelGalleryimportTravelStoryMapimport- Several
createTravelPhotos([...])calls - Body text alternating with image blocks
Travel article front matter also handles category page sorting. The current convention uses the trip start date as sidebar_position: -YYYYMMDD, e.g., sidebar_position: -20260207. Docusaurus autogenerated sidebar sorts by ascending sidebar_position, so newer dates produce smaller negatives and rank higher. This keeps the Travel sidebar, prev/next navigation, and Travel homepage cards in a consistent "newest trip first" order.
Do not use 1, 2, 3... for ordering. That approach requires renumbering every time a newer trip is added, and maintenance cost grows over time. Do not rename Travel files for sorting purposes either; the current map data and homepage display still use the file basename as the article id. Renaming would break card covers, card descriptions, and stable associations in generated data.
Article Generation Layer: Why create-travel-article Exists as a Separate Step
Key script:
scripts/create-travel-article.mjs
It exists not to be "lazy," but to codify the format specification.
This script currently handles:
- Parsing front matter.
- Tolerating UTF-8 BOM.
- Recognizing Markdown image lines.
- Only accepting JPEGs under
picture.nevergpdzy.cnor already-normalized JPEG relative paths. - Converting full OSS URLs to the repo's unified relative paths. If only a filename is given, it falls back to
img_for_Typora/per legacy rules. - Preserving
##section structure. - Auto-generating
createTravelPhotos([...]). - Auto-inserting
<TravelStoryMap />. - Optionally chaining into
generate:travelorbuild.
Current CLI behavior:
--source/-spoints to the source draft; required.--syncrunsnpm run generate:travelafter generating the MDX.--verifyrunsnpm run generate:travelandnpm run buildafter generating the MDX.--forceallows overwriting an existing target file. Without this flag, the script refuses to overwrite.- The
outputfield in the source draft's front matter is only used to specify the output path and is not written into the final MDX.
The core idea at this layer:
- Codify the format specification into the script, not into the human brain.
This way, whether you hand-write articles or AI helps, the structure landing in the repo stays as consistent as possible.
Photo Metadata Generation Layer: Why the Frontend Does Not Read EXIF Directly
Key script:
scripts/generate-travel-photo-metadata.mjs
The frontend is not the right place to process EXIF, for several reasons:
- Images are too large.
- Browser-side parsing is unstable and wastes performance.
- Reverse geocoding requires request throttling and caching.
- A built static site is better suited to consuming ready-made JSON.
So the current approach is:
- Scan JPEGs referenced in
docs/Travel/*.mdx. - Normalize all references to canonical image keys.
- Download the images.
- Parse
DateTimeOriginal,Model,LensModel, and GPS from the JPEGs. - If GPS exists, call AMap Web Service reverse geocoding.
- Generate
travelPhotoMetadata.generated.json.
This layer also handles several small but maintenance-critical details:
- Reuse existing caches to avoid redundant requests
- Throttle AMap requests to avoid hammering the API
- Normalize iPhone lens names
- Allow fallback to the article-title-inherited location name when GPS is unavailable
Map Data Generation Layer: Why Another Layer with travelMap.generated.json
Key script:
scripts/generate-travel-map-data.mjs
It might seem that the frontend already has photo metadata and could draw maps directly, but the current project does not do that. The frontend is still missing several types of "article-level information":
- Which permalink corresponds to this article
- What is the order of each
TravelGallery - Which image belongs to which section
- Which point is suitable as the article overview point
- Which point should represent "visited this place" on the Travel category page
So this script separately organizes the "map-renderable" structure and hands it to the frontend.
Key design decisions at this layer:
1. Maps Show Points, Not Routes
The current Travel maps no longer draw routes. They only show:
- Photo capture points on article pages
- Place representative points on the category page
The reason: photo GPS is better suited to expressing "where this was captured" than "the complete route taken."
2. The Category Page Uses Representative Points, Not All Points Spread Out
If the category page spread out every point from an article, the information density would be too high and hard to click.
So the script computes a suitable representative point per article and aggregates them into the overview map.
3. Section Order Continues to Be Inherited from the Article
Map data does not independently define sections. It reads back the TravelGallery and heading order from the article.
This ensures the map and the body text do not tell different stories.
Display Layer: Why TravelGallery and TravelMap Are Separated
TravelHome
Key files:
src/components/TravelHome/index.jssrc/components/TravelHome/styles.module.css
This layer handles the Travel category homepage, not the article detail page.
It currently does four things:
- Renders the top "My Footprints" overview card.
- Renders the embedded footprint map.
- Renders the travel article card list.
- On desktop, renders a bottom quote. On mobile, hides the quote to avoid crowding the page footer.
TravelHome content data still comes from src/data/travelMap.generated.json. It does not maintain a separate travel article list. The card display order comes from Docusaurus's already-sorted Travel sidebar items by sidebar_position.
Specific rules:
- Place count and article count come from
travelMapData.placeCount / articleCount. - Travel article card content comes from
travelMapData.articles. - Travel article card order comes from the Travel category's
sidebarItems, staying consistent with the left sidebar and prev/next navigation. - If an article has not yet appeared in
sidebarItems,TravelHomeappends it after the sorted articles to prevent the card from disappearing due to missing sort data. - Card cover images first read from
CARD_COVER_IMAGESconfigured by articleid. If not configured, they fall back to the first photo in the article, assembled using thepicture.nevergpdzy.cnremote image rule. - Card descriptions use display copy from
CARD_DESCRIPTIONS, not the long description from front matter. - Flight, train, and driving mileage are manually curated display data on the Travel homepage, not derived from photo metadata.
CARD_COVER_IMAGES only affects Travel homepage card covers. It does not modify docs/Travel/*.mdx body albums, article descriptions, or generated data in src/data/travelMap.generated.json.
CARD_DESCRIPTIONS only affects Travel homepage cards. It does not modify docs/Travel/*.mdx article descriptions.
There is a commonly misunderstood boundary: the travelMap.generated.json article list is generated from filenames. The actual display order is then re-sorted by TravelHome based on the sidebarItems passed in by Docusaurus. In other words, when adding a new article, first ensure sidebar_position is correct, then verify through a build that the homepage cards, sidebar, and prev/next navigation are consistent.
When adding a new Travel article, the front matter must include the negative sort value corresponding to the trip start date:
sidebar_position: -20260430
For multiple articles on the same day, do not append integer digits (e.g., do not write -2026043001), because it will be smaller than normal dates and permanently rank higher. Use decimal suffixes for same-day fine-tuning:
sidebar_position: -20260430.02
sidebar_position: -20260430.01
Decimal suffixes only adjust order within the same day and do not cross adjacent dates.
Travel Homepage Visual Structure
The Travel homepage is not a simple reskin of the Docusaurus generated index. It is a dedicated homepage view.
Current structure:
- Left: "My Footprints" overview card.
- Right: embedded map card.
- Below: travel story card grid.
- Desktop bottom: Chinese quote with full-width quote marks.
A few boundaries to watch during maintenance:
- The left overview card background always uses
https://picture.nevergpdzy.cn/AI-images/tranquil-valley-path.png. - The stats area is fixed at two rows: row one is "Places Explored / Travel Stories," row two is "Flights / Trains / Drives."
- Stats are left-aligned. Text is left-aligned to the right of the icon. Icons are vertically centered in their own light-colored square.
- The right map card no longer shows an extra title bar or "7 Places," giving the map more display area.
- Travel article cards do not show dates or view counts -- only location pills, cover, title, and short description.
- The bottom quote uses Chinese full-width quote marks on desktop; hidden on mobile.
The visual goal of this page is "travel overview," not a marketing landing page and not a plain doc directory page. Do not re-introduce the default DocCardList list style.
TravelGallery
Key file:
src/components/TravelGallery/index.js
This layer handles:
- Normalizing image references
- Assembling remote image URLs
- Filling in capture time, device, lens, and location from
travelPhotoMetadata.generated.json - Rendering grid albums and lightbox
It does not handle:
- Determining article structure
- Requesting AMap
- Parsing EXIF
TravelMap
Key files:
src/components/TravelMap/shared.jssrc/components/TravelMap/TravelStoryMap.jssrc/components/TravelMap/TravelOverviewMap.jssrc/components/TravelMap/TravelMapDisclosure.js
This splits into three parts:
shared.jsHandles AMap config reading, loader singleton, map creation, control integration, coordinate conversion,fit view, destruction, and resize.TravelStoryMap.jsHandles article page collapsed map, photo point clustering, markers, and click info windows.TravelOverviewMap.jsHandles place overview map and article jump. Default collapsed; also supports theembeddedmode used by the Travel homepage.
TravelOverviewMap has two modes to distinguish:
- Default mode: uses
TravelMapDisclosure; the inline map is created only after clicking. Suitable for non-above-the-fold supplementary maps. embeddedmode: the map is created immediately on component mount. Suitable for the above-the-fold footprint map on the Travel homepage.
Both modes share the same place data, coordinate conversion, marker creation, fullscreen popup, and failure fallback.
Why shared.js Is a Common Layer
For the map to run stably, it depends on a shared set of rules:
- Unified reading of
key / serviceHost / securityJsCode - Unified setting of
window._AMapSecurityConfig - Unified lazy loading of
@amap/amap-jsapi-loader - Unified device-capability-based decision on loading
AMap.ToolBarandAMap.Scale - Unified tightening of map render parameters -- default to
2D, disable rotation / pitch / 3D buildings, keep only essential base map elements - Unified
gps -> AMap coordinateconversion - Unified
fitTravelMapToOverlays - Unified
map.destroy()on component collapse or unmount
If this logic scatters into individual map components, behavior drift is inevitable.
AMap Integration Layer: Why Both serviceHost and securityJsCode Are Supported
Key files:
docusaurus.config.jssrc/components/TravelMap/shared.js
The core tension here:
- The frontend needs
AMAP_JSAPI_KEYto load the AMap JSAPI - But
AMAP_SECURITY_JS_CODEshould not be permanently exposed to the client in production
So the current strategy:
- In local or no-proxy environments,
securityJsCodecan serve as a fallback. - Production prefers
AMAP_SERVICE_HOST. - Once
AMAP_SERVICE_HOSTis configured, the build no longer injectsAMAP_SECURITY_JS_CODEinto the frontendcustomFields.amap.
The logic is in docusaurus.config.js: when AMAP_SERVICE_HOST has a value, customFields.amap.securityJsCode is written as an empty string; otherwise it reads AMAP_SECURITY_JS_CODE or its fallback. The frontend uniformly sets window._AMapSecurityConfig via src/utils/amap.js.
The benefits:
- Local debugging remains simple
- The real security key stays in the server proxy layer for production
Coordinate Handling: Why "Convert at Render Time" Instead of "Overwrite at Generation Time"
This is important enough to restate.
The current data stores raw EXIF GPS, meaning:
- Coordinates in
travelPhotoMetadata.generated.jsonare still raw GPS - Points in
travelMap.generated.jsonalso continue using this raw GPS
The actual conversion to AMap-renderable coordinates happens on the frontend when calling:
AMap.convertFrom(..., 'gps')
This design is intentional, not an oversight. The reasons:
- Raw GPS is more universal.
- Generation scripts should not silently write coordinates that only work with one map vendor.
- When switching map vendors, you only need to change the rendering layer -- no need to reprocess historical data.
Related troubleshooting notes:
Map Interaction: Why Article Pages Collapsed and Homepage Renders Directly
There are now two map interaction modes:
- Article page capture point map: collapsed via
TravelMapDisclosure. - Travel homepage footprint map: rendered directly via
TravelOverviewMap embedded.
Article pages stay collapsed for three reasons:
- The map should not overpower the body text.
- AMap JSAPI loading cost is non-trivial.
- Initializing the map only after click reduces unnecessary WebGL instances.
The Travel homepage renders directly for clear reasons:
- The footprint map is part of the homepage's above-the-fold content, not a post-article supplement.
- Users visiting the Travel homepage expect to see "where they have been" first.
- The map is already compressed into a compact card and will not interrupt reading like a full article-page map.
Current behavior:
TravelStoryMapdoes not expand by default; the map is only created after the user clicks.TravelOverviewMapstill supports collapsible mode to maintain component generality.TravelHomecallsTravelOverviewMap embedded, creating the map immediately on mount.- In collapsible mode, the inline map is destroyed when the panel collapses to avoid hidden WebGL resource usage.
- In embedded mode, window resize events trigger map resize requests to keep the canvas size correct after responsive layout changes.
- The map instance is destroyed when the page unmounts.
Travel Homepage Responsive Rules
The Travel homepage responsive behavior is not a simple desktop scale-down. It redistributes attention by device.
Desktop
- Top is a two-column layout: left "My Footprints" is wider, right map is narrower.
- Left and right card heights stay consistent; the map canvas must not stretch the entire row.
- Stats area is fixed at two rows left-aligned; do not stretch to fill the row.
- Article cards prefer multi-column display to avoid oversized individual cards.
- Bottom quote displays with Chinese full-width quote marks.
Desktop Widescreen and Sidebar Rules
The Travel homepage should be treated as a standalone display page on desktop, not a regular docs article directory page. Maintenance must ensure both entry points use a no-sidebar widescreen layout:
/docs/Travel/docs/category/travel
Both entry points should be recognized as Travel pages by isTravelDocPath in src/utils/travelDocs.js. Then at the DocRoot/Layout layer, skip DocRootLayoutSidebar and let DocRoot/Layout/Main use the Travel-specific widescreen container.
The reasons:
- The left Docusaurus sidebar squeezes the above-the-fold display area, making "My Footprints" and the map card narrow.
- The two top cards on the Travel homepage need a stable left-right ratio -- roughly
2:1on desktop widescreen. - Travel story cards below should maintain four per row on desktop widescreen, with the display area centered overall and cards scaling proportionally with available width.
If you later adjust Travel homepage routes or Docusaurus generated index config, you must also check the Travel path logic in src/utils/travelDocs.js. Otherwise, one entry point may still have a sidebar while the other does not -- an inconsistent split.
Mobile
- The top overview card is pushed down as much as possible to avoid a hero image filling the entire first screen.
- Stats are still two rows left-aligned, but each chip only occupies space based on content and minimum width.
- Map height is independently reduced -- it serves only as a "sense of place" entry, not for large-area map browsing.
- Article cards display in a single column, but image ratios are tightened to avoid each card looking like a huge poster.
- Bottom quote is hidden to avoid crowding with the footer.
During maintenance, do not revert mobile to a scaled-down desktop layout. Scan efficiency and touch feel matter more here.
Mobile Interaction Performance Optimization: Why Intentional "Reduced Configuration"
This section is easy to "accidentally add back" during future maintenance, so it is recorded separately.
After the project went live, the most immediate mobile issue was not "too many points" but the per-frame cost being too high during scroll, drag, and pinch-zoom.
There were three main risk areas:
- The map once exhibited fallback behavior of initializing immediately on pages where it should not.
- Although the map instance did not use pitch, rotation, or 3D building effects, it still initialized in
3Dmode. - Mobile still loaded
ToolBar,Scale, and other desktop-oriented controls.
The current principle:
- Travel maps prioritize smooth mobile interaction, not "parameters look maxed out."
- If the current page does not explicitly use 3D perspective, do not retain 3D overhead for "theoretically more powerful."
- Hidden map instances are not kept alive. Collapsed maps are destroyed immediately and recreated on re-expand.
- The Travel homepage, which explicitly needs an above-the-fold map, can use embedded mode but must control map dimensions.
Specific implementation rules:
shared.jscreates maps in2Dmode by default.pitchEnable,rotateEnable,showBuildingBlock, andshowIndoorMapare all disabled.- The base map keeps only
bg / road / pointand does not open heavier layer elements this page does not need. AMap.ToolBarandAMap.Scaleare not loaded by default on coarse-pointer devices (phones, tablets).TravelOverviewMapdefault mode stays collapsed; onlyembeddedmode auto-initializes.- Article page inline maps call
destroy()immediately on collapse. - The Travel homepage embedded map shows no extra title bar, reducing UI obstruction and giving the map more space.
This scaling-down is not about "cutting features." The Travel map's role is to assist reading:
- Seeing point distribution is enough
- No need to showcase 3D city effects here
- No need to maintain a hidden active WebGL instance for a handful of points
If this section eventually evolves into high-density points or long-interaction scenarios, consider switching overlays to LabelMarker-style performance-oriented implementations rather than re-enabling 3D and desktop controls first.
Why This Implementation No Longer Keeps "Routes"
This is an explicit product trade-off, not an unfinished feature.
The main problems with routes were:
- Visually they looked like track logs, not albums
- Most travel photo groups' GPS points do not suit linear narration
- Some photos lack GPS, making routes inherently incomplete
- The more the map looked like a "navigation product," the more it stole focus from the body text
So this iteration changed to:
- Article pages only mark capture points
- The category page only marks places visited
- Point descriptions still come from photo metadata and article structure
This line better fits the Travel section's positioning on this site.
Why This Implementation Fits Your Current Workflow
Your most common input today is not a complete MDX submitted directly, but:
- A location
- A set of OSS image links
- A few words about your feelings and style preferences
So the current system deliberately accommodates this input style:
- A person can provide just a short description first
- AI fills in the source draft
- Then the article is auto-generated
- Then metadata and maps are auto-generated
This is more practical than requiring you to hand-write every trip into fully spec-compliant MDX.
Rules to Follow When Extending This System
1. Do Not Hand-Edit Generated JSON
These two files are generated artifacts:
src/data/travelPhotoMetadata.generated.jsonsrc/data/travelMap.generated.json
Change the scripts to change the logic. Do not edit the JSON directly.
2. Reuse shared.js When Adding New Map Components
Do not re-write in new components:
- AMap loader initialization
- Security configuration
- Coordinate conversion
fit view- Destruction logic
3. New Content Should Follow the "Source Draft -> Final MDX" Pipeline
Do not regress to hand-assembling createTravelPhotos([...]) every time.
4. Production Should Use a Proxy
Do not publish AMAP_SECURITY_JS_CODE alongside a static build by default.
5. Do Not Re-Introduce Route Logic
Unless the Travel section's product direction truly changes, do not let the map shift from "capture point overview" back to "navigation route map."
Most Commonly Used Commands
npm run create:travel-article -- --source drafts/travel/<slug>-source.md
npm run sync:travel-article -- --source drafts/travel/<slug>-source.md
npm run create:travel-article -- --source drafts/travel/<slug>-source.md --verify
npm run generate:travel
npm run verify:travel
These commands correspond to:
- Generate the article
- Generate the article and sync metadata and map data
- Generate the article, sync metadata and map data, and build-verify
- Refresh metadata and map data only
- Refresh and build-verify
Where to Start When Revisiting This Implementation
If you or someone else needs to continue maintaining this feature set, the recommended reading order is:
docs/LabNotes/travel-photo-metadata-workflow.mdscripts/create-travel-article.mjsscripts/generate-travel-photo-metadata.mjsscripts/generate-travel-map-data.mjssrc/components/TravelGallery/index.jssrc/components/TravelHome/index.jssrc/components/TravelHome/styles.module.csssrc/components/TravelMap/shared.jssrc/components/TravelMap/TravelStoryMap.jssrc/components/TravelMap/TravelOverviewMap.js
Reading in this order makes it easiest to first understand how data flows, then understand why the frontend renders things this way.
One-Sentence Summary
The core of the current Travel feature is not "we integrated AMap." It is:
Use article structure as the single source of truth, use scripts to generate metadata and map data, and let the frontend only handle rendering -- turning "travel photos + copy + map" into a repeatable, maintainable content production pipeline.