diff --git a/package.json b/package.json index 3c6640e10..14a3bb96e 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "clsx": "^2.0.0", "form-data": "^4.0.4", "gray-matter": "^4.0.3", + "leaflet": "^1.9.4", "lodash": "^4.17.21", "mailgun.js": "^12.0.3", "moment": "^2.29.4", @@ -59,6 +60,7 @@ "react": "^18", "react-dom": "^18", "react-hook-form": "^7.48.2", + "react-leaflet": "^4.2.1", "react-markdown": "^9.0.1", "react-photo-album": "^2.3.0", "react-syntax-highlighter": "^15.5.0", @@ -79,6 +81,7 @@ "@testing-library/jest-dom": "^6.4.5", "@testing-library/react": "^15.0.7", "@types/jest": "^29.5.11", + "@types/leaflet": "^1.9.12", "@types/lodash": "^4.17.6", "@types/node": "^20", "@types/react": "^18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57d4024fa..9ce414395 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ dependencies: gray-matter: specifier: ^4.0.3 version: 4.0.3 + leaflet: + specifier: ^1.9.4 + version: 1.9.4 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -107,6 +110,9 @@ dependencies: react-hook-form: specifier: ^7.48.2 version: 7.48.2(react@18.2.0) + react-leaflet: + specifier: ^4.2.1 + version: 4.2.1(leaflet@1.9.4)(react-dom@18.2.0)(react@18.2.0) react-markdown: specifier: ^9.0.1 version: 9.0.1(@types/react@18.2.37)(react@18.2.0) @@ -163,6 +169,9 @@ devDependencies: '@types/jest': specifier: ^29.5.11 version: 29.5.11 + '@types/leaflet': + specifier: ^1.9.12 + version: 1.9.21 '@types/lodash': specifier: ^4.17.6 version: 4.17.6 @@ -2407,6 +2416,18 @@ packages: '@babel/runtime': 7.23.5 dev: false + /@react-leaflet/core@2.1.0(leaflet@1.9.4)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==} + peerDependencies: + leaflet: ^1.9.0 + react: ^18.0.0 + react-dom: ^18.0.0 + dependencies: + leaflet: 1.9.4 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@rushstack/eslint-patch@1.5.1: resolution: {integrity: sha512-6i/8UoL0P5y4leBIGzvkZdS85RDMG9y1ihZzmTZQ5LdHUYmZ7pKFoj8X0236s3lusPs1Fa5HTQUpwI+UfTcmeA==} dev: true @@ -2633,6 +2654,10 @@ packages: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} dev: false + /@types/geojson@7946.0.16: + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + dev: true + /@types/graceful-fs@4.1.9: resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} dependencies: @@ -2690,6 +2715,12 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: true + /@types/leaflet@1.9.21: + resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} + dependencies: + '@types/geojson': 7946.0.16 + dev: true + /@types/lodash@4.17.6: resolution: {integrity: sha512-OpXEVoCKSS3lQqjx9GGGOapBeuW5eUboYHRlHP9urXPX25IKZ6AnP5ZRxtVf63iieUbsHxLn8NQ5Nlftc6yzAA==} dev: true @@ -6455,6 +6486,10 @@ packages: language-subtag-registry: 0.3.22 dev: true + /leaflet@1.9.4: + resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} + dev: false + /leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -7762,6 +7797,19 @@ packages: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} dev: true + /react-leaflet@4.2.1(leaflet@1.9.4)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==} + peerDependencies: + leaflet: ^1.9.0 + react: ^18.0.0 + react-dom: ^18.0.0 + dependencies: + '@react-leaflet/core': 2.1.0(leaflet@1.9.4)(react-dom@18.2.0)(react@18.2.0) + leaflet: 1.9.4 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react-markdown@9.0.1(@types/react@18.2.37)(react@18.2.0): resolution: {integrity: sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==} peerDependencies: diff --git a/src/components/molecules/CustomMarkdown/CustomMarkdown.tsx b/src/components/molecules/CustomMarkdown/CustomMarkdown.tsx index 0b26fc8bf..8d8e0797e 100644 --- a/src/components/molecules/CustomMarkdown/CustomMarkdown.tsx +++ b/src/components/molecules/CustomMarkdown/CustomMarkdown.tsx @@ -9,6 +9,7 @@ import rehypeRaw from "rehype-raw"; import { classNames } from "@/utils/style"; import { CustomLink } from "@/components/atoms/CustomLink/CustomLink"; import { resolveBlogImage } from "@/utils/cdn/cdnAssets"; +import { LeafletMapEmbed } from "@/components/organisms/LeafletMap/LeafletMapEmbed"; type CustomMarkdownProps = { children: string; @@ -103,6 +104,11 @@ export const CustomMarkdown: React.FC = ({ }, }; + // Custom HTML tags that aren't part of react-markdown's element typings. + // Authors can embed an interactive map in a post via ``. + (MarkdownComponents as Record)["leaflet-map"] = + LeafletMapEmbed; + return ( OpenStreetMap contributors'; + +/** + * When no explicit center is provided, fit the viewport to the bounds of all + * pins so every marker is visible. + */ +const FitToPins: React.FC<{ points: LatLng[] }> = ({ points }) => { + const map = useMap(); + + useEffect(() => { + if (points.length === 0) return; + if (points.length === 1) { + map.setView(points[0], 13); + return; + } + const bounds = L.latLngBounds(points); + map.fitBounds(bounds, { padding: [40, 40] }); + }, [map, points]); + + return null; +}; + +/** + * Interactive Leaflet map. Renders a set of pins, each with a customizable + * marker (emoji, colored dot, text label, or built-in vector icon) plus an + * optional hover tooltip and click popup. + * + * This is a client-only component (Leaflet needs `window`). When embedding in + * SSR contexts, import it through a `next/dynamic` wrapper with `ssr: false` + * (see `LeafletMapEmbed`). + */ +export const LeafletMap: React.FC = ({ + pins = [], + center, + zoom = 13, + height = 400, + scrollWheelZoom = false, + tileUrl = DEFAULT_TILE_URL, + attribution = DEFAULT_ATTRIBUTION, + className, +}) => { + const points: LatLng[] = pins.map((p) => [p.lat, p.lng]); + + // A center is required to mount MapContainer; fall back to the first pin or + // a neutral world view. FitToPins refines this once mounted. + const initialCenter: LatLng = center ?? points[0] ?? [20, 0]; + const initialZoom = center ? zoom : points.length > 0 ? zoom : 2; + + return ( +
+ + + + {!center && } + + {pins.map((pin, index) => ( + + {pin.label && {pin.label}} + {pin.popup && ( + + + + )} + + ))} + +
+ ); +}; + +export default LeafletMap; diff --git a/src/components/organisms/LeafletMap/LeafletMapEmbed.tsx b/src/components/organisms/LeafletMap/LeafletMapEmbed.tsx new file mode 100644 index 000000000..92826b51f --- /dev/null +++ b/src/components/organisms/LeafletMap/LeafletMapEmbed.tsx @@ -0,0 +1,105 @@ +"use client"; + +import dynamic from "next/dynamic"; +import React from "react"; +import type { LatLng, LeafletMapProps, MapPin } from "./types"; + +/** + * Lazily load the map with SSR disabled. Leaflet touches `window` at import + * time, so it can only run in the browser. `ssr: false` is permitted here + * because this file is a Client Component. + */ +const LeafletMap = dynamic(() => import("./LeafletMap"), { + ssr: false, + loading: () => ( +
+ Loading map… +
+ ), +}); + +/** + * Props as they arrive from `react-markdown` when authors embed a + * `` tag in a blog post. All HTML attributes are strings, and + * the raw hast node is available via `node` for attributes react-markdown does + * not surface as typed props. + */ +type RawAttrs = Record & { + node?: { properties?: Record }; +}; + +/** Read an attribute by name, tolerating casing and the hast `node` fallback. */ +const readAttr = (props: RawAttrs, name: string): string | undefined => { + const lower = name.toLowerCase(); + const fromProps = props[name] ?? props[lower]; + const fromNode = props.node?.properties?.[name] ?? props.node?.properties?.[lower]; + const value = fromProps ?? fromNode; + return value == null ? undefined : String(value); +}; + +const parseCenter = (raw?: string): LatLng | undefined => { + if (!raw) return undefined; + const parts = raw.split(",").map((n) => Number(n.trim())); + if (parts.length !== 2 || parts.some((n) => Number.isNaN(n))) return undefined; + return [parts[0], parts[1]]; +}; + +const parsePins = (raw?: string): MapPin[] => { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as MapPin[]) : []; + } catch (err) { + if (process.env.NODE_ENV !== "production") { + // eslint-disable-next-line no-console + console.warn("[leaflet-map] Failed to parse `pins` attribute:", err); + } + return []; + } +}; + +/** + * Markdown-facing wrapper for the interactive map. Registered against the + * custom `` tag in `CustomMarkdown`, it translates HTML attributes + * into typed props and renders the SSR-safe map. + * + * Example (inside a `.md`/`.mdx` blog post): + * + * ```html + * + * ``` + */ +export const LeafletMapEmbed: React.FC = (props) => { + // Direct React usage: typed props are passed through untouched. + if (Array.isArray((props as Partial).pins)) { + return ; + } + + const zoomRaw = readAttr(props, "zoom"); + const heightRaw = readAttr(props, "height"); + const scrollRaw = readAttr(props, "scrollWheelZoom"); + + const config: LeafletMapProps = { + pins: parsePins(readAttr(props, "pins")), + center: parseCenter(readAttr(props, "center")), + zoom: zoomRaw ? Number(zoomRaw) : undefined, + height: heightRaw + ? /^\d+$/.test(heightRaw) + ? Number(heightRaw) + : heightRaw + : undefined, + scrollWheelZoom: scrollRaw ? scrollRaw === "true" : undefined, + tileUrl: readAttr(props, "tileUrl"), + attribution: readAttr(props, "attribution"), + }; + + return ; +}; + +export default LeafletMapEmbed; diff --git a/src/components/organisms/LeafletMap/README.md b/src/components/organisms/LeafletMap/README.md new file mode 100644 index 000000000..edf9d152c --- /dev/null +++ b/src/components/organisms/LeafletMap/README.md @@ -0,0 +1,86 @@ +# LeafletMap + +An interactive [Leaflet](https://leafletjs.com/) map for blog posts. Renders +pins with fully customizable markers: **emoji**, a **colored dot**, a **text +label**, or a **built-in vector icon**. Each pin can show a hover tooltip and a +click popup. + +It is client-only (Leaflet needs `window`), so it is loaded through a +`next/dynamic` wrapper with `ssr: false`. Blog posts are rendered with +`react-markdown` + `rehype-raw` (not true MDX), so the map is embedded via a +custom `` HTML tag whose config is passed as attributes. + +## Embedding in a blog post (`.md` / `.mdx`) + +```html + +``` + +> The `pins` attribute is a JSON string — wrap it in single quotes so the inner +> double quotes stay valid. If `center` is omitted, the map auto-fits to show +> all pins. + +### Attributes + +| Attribute | Type | Default | Notes | +| ----------------- | -------------------------- | ---------------- | ------------------------------------------------ | +| `pins` | JSON array string | `[]` | See pin shape below. | +| `center` | `"lat,lng"` | auto-fit to pins | Initial center. | +| `zoom` | number | `13` | Ignored when auto-fitting. | +| `height` | number (px) or CSS length | `400` | e.g. `"420"` or `"50vh"`. | +| `scrollWheelZoom` | `"true"` / `"false"` | `false` | Off by default so the page scrolls naturally. | +| `tileUrl` | string | OpenStreetMap | Tile layer URL template. | +| `attribution` | string | OpenStreetMap | Tile attribution. | + +### Pin shape + +```jsonc +{ + "lat": 40.7128, // required + "lng": -74.006, // required + "label": "Tooltip", // optional hover tooltip + "popup": "HTML",// optional click popup (trusted HTML or text) + "icon": { /* see below; defaults to a blue dot */ } +} +``` + +### Icon types + +```jsonc +{ "type": "emoji", "value": "🗽", "size": 28 } +{ "type": "dot", "color": "#3b82f6", "size": 16 } +{ "type": "text", "value": "Ramen", "color": "#fff", "background": "#1e293b" } +{ "type": "icon", "name": "star", "color": "#16a34a", "size": 28 } +``` + +Built-in `icon` names: `pin`, `star`, `home`, `heart`, `flag`, `camera`, +`food`, `coffee`. + +## Using directly in a React component + +```tsx +import dynamic from "next/dynamic"; + +const LeafletMap = dynamic( + () => import("@/components/organisms/LeafletMap/LeafletMap"), + { ssr: false }, +); + +; +``` diff --git a/src/components/organisms/LeafletMap/icons.ts b/src/components/organisms/LeafletMap/icons.ts new file mode 100644 index 000000000..85f360bb2 --- /dev/null +++ b/src/components/organisms/LeafletMap/icons.ts @@ -0,0 +1,107 @@ +import L from "leaflet"; +import type { MapPin, PinIcon, PinIconName } from "./types"; + +/** + * Minimal SVG path data (24x24 viewBox) for the built-in vector icons. Using + * inline SVG keeps the component dependency-free and lets markers be recolored + * via `currentColor`. + */ +const ICON_PATHS: Record = { + pin: "M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 110-5 2.5 2.5 0 010 5z", + star: "M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z", + home: "M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z", + heart: + "M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54z", + flag: "M14.4 6L14 4H5v17h2v-7h5.6l.4 2h7V6z", + camera: + "M9 2L7.17 4H4a2 2 0 00-2 2v12a2 2 0 002 2h16a2 2 0 002-2V6a2 2 0 00-2-2h-3.17L15 2H9zm3 15a5 5 0 110-10 5 5 0 010 10z", + food: "M11 9H9V2H7v7H5V2H3v7c0 2.12 1.66 3.84 3.75 3.97V22h2.5v-9.03C11.34 12.84 13 11.12 13 9V2h-2v7zm5-3v8h2.5v8H21V2c-2.76 0-5 2.24-5 4z", + coffee: + "M20 3H4v10a4 4 0 004 4h6a4 4 0 004-4v-3h2a2 2 0 002-2V5a2 2 0 00-2-2zm0 5h-2V5h2v3zM4 19h16v2H4z", +}; + +/** Escape a string for safe interpolation into marker HTML. */ +const escapeHtml = (value: string): string => + value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + +const DEFAULT_ICON: PinIcon = { type: "dot", color: "#3b82f6" }; + +/** + * Build a Leaflet `DivIcon` from a pin's icon spec. Using divIcons for every + * marker means we never rely on Leaflet's default image assets (which break + * under bundlers) and lets us render emoji, colored dots, text, and SVGs. + */ +export const createPinIcon = (pin: MapPin): L.DivIcon => { + const icon = pin.icon ?? DEFAULT_ICON; + + switch (icon.type) { + case "emoji": { + const size = icon.size ?? 28; + return L.divIcon({ + className: "leaflet-custom-marker", + html: `
${escapeHtml( + icon.value, + )}
`, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + popupAnchor: [0, -size / 2], + }); + } + + case "dot": { + const size = icon.size ?? 16; + return L.divIcon({ + className: "leaflet-custom-marker", + html: `
`, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + popupAnchor: [0, -size / 2], + }); + } + + case "text": { + const color = icon.color ?? "#ffffff"; + const background = icon.background ?? "#1e293b"; + return L.divIcon({ + className: "leaflet-custom-marker", + html: `
${escapeHtml( + icon.value, + )}
`, + // Width is content-driven; let Leaflet size it automatically. + iconSize: undefined as unknown as L.PointExpression, + iconAnchor: [0, 12], + popupAnchor: [0, -12], + }); + } + + case "icon": { + const size = icon.size ?? 28; + const color = icon.color ?? "#3b82f6"; + const path = ICON_PATHS[icon.name] ?? ICON_PATHS.pin; + return L.divIcon({ + className: "leaflet-custom-marker", + html: `
`, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + popupAnchor: [0, -size / 2], + }); + } + + default: { + // Exhaustiveness guard — fall back to the default dot. + return createPinIcon({ ...pin, icon: DEFAULT_ICON }); + } + } +}; diff --git a/src/components/organisms/LeafletMap/index.ts b/src/components/organisms/LeafletMap/index.ts new file mode 100644 index 000000000..608dd4988 --- /dev/null +++ b/src/components/organisms/LeafletMap/index.ts @@ -0,0 +1,10 @@ +export { LeafletMap, default as LeafletMapClient } from "./LeafletMap"; +export { LeafletMapEmbed } from "./LeafletMapEmbed"; +export { createPinIcon } from "./icons"; +export type { + LatLng, + LeafletMapProps, + MapPin, + PinIcon, + PinIconName, +} from "./types"; diff --git a/src/components/organisms/LeafletMap/types.ts b/src/components/organisms/LeafletMap/types.ts new file mode 100644 index 000000000..8bee16379 --- /dev/null +++ b/src/components/organisms/LeafletMap/types.ts @@ -0,0 +1,74 @@ +/** + * Types for the interactive Leaflet map component. + * + * These are intentionally serializable (plain JSON) so that a map can be + * configured both from React (passing props directly) and from within a blog + * post (passing a JSON string through a custom `` HTML tag). + */ + +/** Built-in vector icons that ship with the map component. */ +export type PinIconName = + | "pin" + | "star" + | "home" + | "heart" + | "flag" + | "camera" + | "food" + | "coffee"; + +/** + * The visual marker for a pin. One of: + * - `emoji`: render an emoji glyph (e.g. 🗽, 🍜, 🏔️) + * - `dot`: a filled circle of an arbitrary CSS color + * - `text`: a short text label rendered as a pill + * - `icon`: one of the built-in vector icons, optionally recolored + */ +export type PinIcon = + | { type: "emoji"; value: string; size?: number } + | { type: "dot"; color: string; size?: number } + | { type: "text"; value: string; color?: string; background?: string } + | { type: "icon"; name: PinIconName; color?: string; size?: number }; + +/** A single map marker. */ +export type MapPin = { + /** Latitude in decimal degrees. */ + lat: number; + /** Longitude in decimal degrees. */ + lng: number; + /** + * Hover tooltip text. If omitted but `popup` is present, no tooltip shows. + */ + label?: string; + /** + * Click popup content. Plain text or a small amount of trusted HTML. + */ + popup?: string; + /** The marker's visual style. Defaults to a blue dot. */ + icon?: PinIcon; +}; + +/** A `[latitude, longitude]` coordinate pair. */ +export type LatLng = [number, number]; + +export type LeafletMapProps = { + /** Pins to render on the map. */ + pins?: MapPin[]; + /** + * Initial map center. If omitted, the map fits the bounds of all pins (or + * falls back to a world view when there are no pins). + */ + center?: LatLng; + /** Initial zoom level (1-19). Ignored when fitting to pin bounds. */ + zoom?: number; + /** Map height in pixels (or any CSS length string). Defaults to 400. */ + height?: number | string; + /** Allow zooming with the scroll wheel. Defaults to false (nicer in posts). */ + scrollWheelZoom?: boolean; + /** Tile layer URL template. Defaults to OpenStreetMap. */ + tileUrl?: string; + /** Tile layer attribution. Defaults to OpenStreetMap attribution. */ + attribution?: string; + /** Extra classes for the outer container. */ + className?: string; +};