Skip to content
Draft
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
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
48 changes: 48 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src/components/molecules/CustomMarkdown/CustomMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -103,6 +104,11 @@ export const CustomMarkdown: React.FC<CustomMarkdownProps> = ({
},
};

// Custom HTML tags that aren't part of react-markdown's element typings.
// Authors can embed an interactive map in a post via `<leaflet-map ...>`.
(MarkdownComponents as Record<string, typeof LeafletMapEmbed>)["leaflet-map"] =
LeafletMapEmbed;

return (
<ReactMarkdown
components={MarkdownComponents}
Expand Down
106 changes: 106 additions & 0 deletions src/components/organisms/LeafletMap/LeafletMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"use client";

import "leaflet/dist/leaflet.css";

import L from "leaflet";
import React, { useEffect } from "react";
import {
MapContainer,
Marker,
Popup,
TileLayer,
Tooltip,
useMap,
} from "react-leaflet";
import { createPinIcon } from "./icons";
import type { LatLng, LeafletMapProps } from "./types";
import { classNames } from "@/utils/style";

const DEFAULT_TILE_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
const DEFAULT_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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<LeafletMapProps> = ({
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 (
<div
className={classNames(
"not-prose relative z-0 overflow-hidden rounded-lg border border-slate-200 shadow-sm dark:border-slate-700",
className,
)}
style={{ height: typeof height === "number" ? `${height}px` : height }}
>
<MapContainer
center={initialCenter}
zoom={initialZoom}
scrollWheelZoom={scrollWheelZoom}
style={{ height: "100%", width: "100%" }}
>
<TileLayer url={tileUrl} attribution={attribution} />

{!center && <FitToPins points={points} />}

{pins.map((pin, index) => (
<Marker
key={`${pin.lat},${pin.lng}-${index}`}
position={[pin.lat, pin.lng]}
icon={createPinIcon(pin)}
>
{pin.label && <Tooltip>{pin.label}</Tooltip>}
{pin.popup && (
<Popup>
<span dangerouslySetInnerHTML={{ __html: pin.popup }} />
</Popup>
)}
</Marker>
))}
</MapContainer>
</div>
);
};

export default LeafletMap;
105 changes: 105 additions & 0 deletions src/components/organisms/LeafletMap/LeafletMapEmbed.tsx
Original file line number Diff line number Diff line change
@@ -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: () => (
<div className="not-prose flex h-[400px] w-full items-center justify-center rounded-lg border border-slate-200 bg-slate-50 text-sm text-slate-400 dark:border-slate-700 dark:bg-slate-800">
Loading map…
</div>
),
});

/**
* Props as they arrive from `react-markdown` when authors embed a
* `<leaflet-map ...>` 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<string, unknown> & {
node?: { properties?: Record<string, unknown> };
};

/** 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 `<leaflet-map>` tag in `CustomMarkdown`, it translates HTML attributes
* into typed props and renders the SSR-safe map.
*
* Example (inside a `.md`/`.mdx` blog post):
*
* ```html
* <leaflet-map
* height="420"
* pins='[
* {"lat":40.7128,"lng":-74.006,"label":"NYC","icon":{"type":"emoji","value":"🗽"}},
* {"lat":48.8584,"lng":2.2945,"label":"Paris","icon":{"type":"dot","color":"#ef4444"}}
* ]'
* ></leaflet-map>
* ```
*/
export const LeafletMapEmbed: React.FC<RawAttrs> = (props) => {
// Direct React usage: typed props are passed through untouched.
if (Array.isArray((props as Partial<LeafletMapProps>).pins)) {
return <LeafletMap {...(props as LeafletMapProps)} />;
}

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 <LeafletMap {...config} />;
};

export default LeafletMapEmbed;
Loading