const { useCallback, useEffect, useMemo, useRef, useState } = React;

const profile = window.FUZHOU_PROFILE || {
  name: "上河上",
  eyebrow: "一个人走过的城市",
  description: "把散落在笔记里的地点，重新放回福州。",
  noteCount: 0
};

const notes = Array.isArray(window.FUZHOU_NOTES) ? window.FUZHOU_NOTES : [];

const categories = [
  { key: "all", label: "全部" },
  { key: "神游", label: "神游" },
  { key: "城事", label: "城事" },
  { key: "去处", label: "去处" },
  { key: "闲逛", label: "闲逛" }
];

function Icon({ name, size = 18, strokeWidth = 1.8 }) {
  const common = {
    width: size,
    height: size,
    viewBox: "0 0 24 24",
    fill: "none",
    stroke: "currentColor",
    strokeWidth,
    strokeLinecap: "round",
    strokeLinejoin: "round",
    "aria-hidden": "true"
  };

  const paths = {
    search: <><circle cx="11" cy="11" r="6.5"></circle><path d="m16 16 4.2 4.2"></path></>,
    compass: <><circle cx="12" cy="12" r="8.5"></circle><path d="m14.8 9.2-1.9 4.1-4.1 1.9 1.9-4.1 4.1-1.9Z"></path></>,
    bookmark: <><path d="M6.8 4.5c0-.8.6-1.5 1.5-1.5h7.4c.8 0 1.5.7 1.5 1.5v15l-5.2-3.1-5.2 3.1v-15Z"></path></>,
    close: <><path d="m6 6 12 12"></path><path d="m18 6-12 12"></path></>,
    arrow: <><path d="M5 12h13"></path><path d="m13 6 6 6-6 6"></path></>,
    map: <><path d="m3.5 6.2 5.4-2.3 6.2 2.3 5.4-2.3v14.2l-5.4 2.3-6.2-2.3-5.4 2.3V6.2Z"></path><path d="M8.9 3.9v14.2"></path><path d="M15.1 6.2v14.2"></path></>,
    list: <><path d="M5 6.5h14"></path><path d="M5 12h14"></path><path d="M5 17.5h14"></path><circle cx="3" cy="6.5" r=".7" fill="currentColor" stroke="none"></circle><circle cx="3" cy="12" r=".7" fill="currentColor" stroke="none"></circle><circle cx="3" cy="17.5" r=".7" fill="currentColor" stroke="none"></circle></>,
    external: <><path d="M14 5h5v5"></path><path d="M19 5 11 13"></path><path d="M18 13.5v4.2c0 .7-.6 1.3-1.3 1.3H6.3c-.7 0-1.3-.6-1.3-1.3V7.3C5 6.6 5.6 6 6.3 6h4.2"></path></>,
    layers: <><path d="m12 3 8 4.2-8 4.2-8-4.2L12 3Z"></path><path d="m4 12 8 4.2 8-4.2"></path><path d="m4 16.8 8 4.2 8-4.2"></path></>,
    pin: <><path d="M19 10.2c0 5-7 10.2-7 10.2S5 15.2 5 10.2a7 7 0 1 1 14 0Z"></path><circle cx="12" cy="10.2" r="2.1"></circle></>
  };

  return <svg {...common}>{paths[name]}</svg>;
}

const markerCategoryColors = {
  "城事": "#cf7b59",
  "去处": "#83aaa0",
  "闲逛": "#c8a58b",
  "神游": "#d7b46d"
};

function markerLabel(note) {
  return markerCategoryColors[note.category] ? note.category.slice(0, 1) : (note.marker || "记");
}

function escapeHtml(value) {
  return String(value ?? "")
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#039;");
}

function markerColor(note, clustered) {
  if (clustered) return "#c4a25b";
  const categoryColor = String(note.categoryColor || "");
  return /^#[0-9a-f]{6}$/i.test(categoryColor)
    ? categoryColor
    : (markerCategoryColors[note.category] || "#a5c0b9");
}

function markerIcon(note, selected, label = markerLabel(note), clustered = false) {
  const selectedClass = selected ? " is-selected" : "";
  const clusterClass = clustered ? " is-cluster" : "";
  const safeLabel = escapeHtml(label);
  const accessibleLabel = escapeHtml(clustered
    ? (label === "×" ? "收起同一落点的笔记" : label + " 条笔记在同一落点")
    : (note.category + "：" + note.title));
  return window.L.divIcon({
    className: "fuzhou-marker-shell",
    html: '<span class="map-pin ' + note.coverClass + clusterClass + selectedClass + '" style="--marker-color:' +
      markerColor(note, clustered) + '" role="img" aria-label="' + accessibleLabel + '"><span>' + safeLabel + "</span></span>",
    iconSize: clustered ? [60, 60] : [46, 46],
    iconAnchor: clustered ? [30, 52] : [23, 42],
    popupAnchor: [0, -36]
  });
}

function hasCoordinates(note) {
  return note.lat !== null && note.lat !== undefined && note.lat !== ""
    && note.lng !== null && note.lng !== undefined && note.lng !== ""
    && Number.isFinite(Number(note.lat)) && Number.isFinite(Number(note.lng));
}

function noteImages(note) {
  return Array.isArray(note.images) && note.images.length
    ? note.images
    : (note.coverImage ? [note.coverImage] : []);
}

const assetBaseUrl = String(window.FUZHOU_ASSET_BASE_URL || "").replace(/\/+$/, "");
function assetUrl(source) {
  if (!source || !assetBaseUrl || !source.startsWith("data/assets/")) return source;
  return assetBaseUrl + "/" + source.slice("data/assets/".length);
}

function spreadCoordinate(note, index, count) {
  const latitude = Number(note.lat);
  const longitude = Number(note.lng);
  const angle = -Math.PI / 2 + (index / Math.max(count, 1)) * Math.PI * 2;
  const radius = count <= 8 ? .0014 : (count <= 20 ? .0022 : .0032);
  const longitudeRadius = radius / Math.max(Math.cos(latitude * Math.PI / 180), .2);
  return [
    latitude + Math.sin(angle) * radius,
    longitude + Math.cos(angle) * longitudeRadius
  ];
}

function NoteImage({ note, className = "" }) {
  const [imageFailed, setImageFailed] = useState(false);
  const hasImage = Boolean(note.coverImage) && !imageFailed;

  useEffect(() => {
    setImageFailed(false);
  }, [note.coverImage]);

  return (
    <div className={"note-image-frame " + className + " " + note.coverClass + (hasImage ? " has-image" : " no-image")}>
      {hasImage ? (
        <img src={assetUrl(note.coverImage)} alt={note.title + "的首图"} onError={() => setImageFailed(true)} />
      ) : (
        <div className="cover-placeholder">
          <span className="placeholder-index">{note.noteNo}</span>
          <strong>首图待接入</strong>
          <small>替换 data/notes.js 的 coverImage</small>
        </div>
      )}
      {hasImage ? (
        <>
          <span className="cover-image-shade"></span>
          <span className="cover-index">{note.noteNo}</span>
          <span className="cover-label">{note.coverLabel}</span>
        </>
      ) : null}
    </div>
  );
}

function SwipeSurface({
  children,
  className = "",
  onSwipe,
  title = "",
  onClick,
  onWheel,
  onKeyDown,
  role,
  ariaLabel,
  tabIndex
}) {
  const startX = useRef(null);
  const didSwipe = useRef(false);
  const surfaceRef = useRef(null);

  useEffect(() => {
    const surface = surfaceRef.current;
    if (!surface || !onWheel) return undefined;
    const handleNativeWheel = (event) => onWheel(event);
    surface.addEventListener("wheel", handleNativeWheel, { passive: false });
    return () => surface.removeEventListener("wheel", handleNativeWheel);
  }, [onWheel]);

  const handlePointerDown = (event) => {
    startX.current = event.clientX;
    didSwipe.current = false;
    event.currentTarget.setPointerCapture?.(event.pointerId);
  };
  const handlePointerMove = (event) => {
    if (startX.current !== null && Math.abs(event.clientX - startX.current) > 8) {
      didSwipe.current = true;
    }
  };
  const handlePointerUp = (event) => {
    if (startX.current !== null) {
      const delta = event.clientX - startX.current;
      if (Math.abs(delta) > 32 && onSwipe) onSwipe(delta < 0 ? 1 : -1);
    }
    startX.current = null;
  };
  const handleClick = (event) => {
    if (didSwipe.current) {
      event.preventDefault();
      event.stopPropagation();
      didSwipe.current = false;
      return;
    }
    if (onClick) onClick(event);
  };

  return (
    <div
      ref={surfaceRef}
      className={className}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      onPointerCancel={() => { startX.current = null; }}
      onClick={handleClick}
      onKeyDown={onKeyDown}
      role={role}
      aria-label={ariaLabel}
      tabIndex={tabIndex}
      title={title}
    >
      {children}
    </div>
  );
}

function NoteCardGallery({ note }) {
  const imageList = noteImages(note);
  const [activeIndex, setActiveIndex] = useState(0);
  const [failedSources, setFailedSources] = useState([]);

  useEffect(() => {
    setActiveIndex(0);
    setFailedSources([]);
  }, [note.id]);

  const usableImages = imageList.filter((source) => !failedSources.includes(source));
  const wheelLockRef = useRef(false);
  if (!usableImages.length) {
    return <NoteImage note={note} className="note-cover" />;
  }

  const safeIndex = Math.min(activeIndex, usableImages.length - 1);
  const activeSource = usableImages[safeIndex];
  const markFailed = (source) => {
    setFailedSources((current) => current.includes(source) ? current : current.concat(source));
  };
  const changeImage = (direction, event) => {
    event.stopPropagation();
    setActiveIndex((current) => (current + direction + usableImages.length) % usableImages.length);
  };
  const shiftImage = (direction) => {
    setActiveIndex((current) => (current + direction + usableImages.length) % usableImages.length);
  };
  const handleWheel = (event) => {
    if (usableImages.length <= 1 || event.deltaY === 0) return;
    event.preventDefault();
    event.stopPropagation();
    if (wheelLockRef.current) return;
    wheelLockRef.current = true;
    shiftImage(event.deltaY > 0 ? 1 : -1);
    window.setTimeout(() => {
      wheelLockRef.current = false;
    }, 260);
  };

  return (
    <SwipeSurface
      className={"note-image-frame note-cover note-carousel " + note.coverClass}
      onSwipe={(direction) => usableImages.length > 1 && shiftImage(direction)}
      onWheel={handleWheel}
      title={usableImages.length > 1 ? "悬浮后滚轮切换，左右滑动预览图片" : "查看首图"}
    >
      <div className="note-carousel-stage">
        <img src={assetUrl(activeSource)} alt={note.title + "的第" + (safeIndex + 1) + "张图片"} onError={() => markFailed(activeSource)} />
      </div>
      <span className="cover-image-shade"></span>
      <span className="cover-index">{note.noteNo}</span>
      <span className="cover-label">{note.coverLabel}</span>
      {usableImages.length > 1 ? (
        <>
          <button className="note-carousel-arrow is-prev" type="button" onClick={(event) => changeImage(-1, event)} aria-label="上一张图片">‹</button>
          <button className="note-carousel-arrow is-next" type="button" onClick={(event) => changeImage(1, event)} aria-label="下一张图片">›</button>
          <span className="note-carousel-page">{safeIndex + 1} / {usableImages.length}</span>
          <span className="note-carousel-dots" aria-hidden="true">
            {usableImages.slice(0, 7).map((source, index) => <i key={source} className={index === safeIndex ? "is-active" : ""}></i>)}
            {usableImages.length > 7 ? <b>＋</b> : null}
          </span>
        </>
      ) : null}
    </SwipeSurface>
  );
}

function NoteGallery({ note }) {
  const imageList = noteImages(note);
  const [activeIndex, setActiveIndex] = useState(0);
  const [failedSources, setFailedSources] = useState([]);

  useEffect(() => {
    setActiveIndex(0);
    setFailedSources([]);
  }, [note.id]);

  const usableImages = imageList.filter((source) => !failedSources.includes(source));
  const wheelLockRef = useRef(false);
  if (!usableImages.length) {
    return <NoteImage note={note} className="detail-image" />;
  }

  const safeIndex = Math.min(activeIndex, usableImages.length - 1);
  const activeSource = usableImages[safeIndex];
  const markFailed = (source) => {
    setFailedSources((current) => current.includes(source) ? current : current.concat(source));
  };
  const shiftImage = (direction) => {
    setActiveIndex((current) => (current + direction + usableImages.length) % usableImages.length);
  };
  const handleWheel = (event) => {
    if (usableImages.length <= 1 || event.deltaY === 0) return;
    event.preventDefault();
    event.stopPropagation();
    if (wheelLockRef.current) return;
    wheelLockRef.current = true;
    shiftImage(event.deltaY > 0 ? 1 : -1);
    window.setTimeout(() => {
      wheelLockRef.current = false;
    }, 260);
  };
  const changeImage = (direction, event) => {
    event.stopPropagation();
    shiftImage(direction);
  };
  const handleGalleryKeyDown = (event) => {
    if (usableImages.length <= 1) return;
    if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
      event.preventDefault();
      event.stopPropagation();
      shiftImage(event.key === "ArrowRight" ? 1 : -1);
    }
  };

  return (
    <div className={"note-image-frame detail-image detail-gallery " + note.coverClass}>
      <SwipeSurface
        className="detail-gallery-main"
        onSwipe={(direction) => usableImages.length > 1 && shiftImage(direction)}
        onWheel={handleWheel}
        onKeyDown={handleGalleryKeyDown}
        role="group"
        ariaLabel={usableImages.length > 1 ? "笔记图片，可用滚轮或左右滑动切换" : "笔记图片"}
        tabIndex={0}
        title={usableImages.length > 1 ? "悬浮后滚轮切换，左右滑动切换图片" : "笔记图片"}
      >
        <img src={assetUrl(activeSource)} alt={note.title + "的第" + (safeIndex + 1) + "张图片"} onError={() => markFailed(activeSource)} />
        <span className="cover-image-shade"></span>
        <span className="cover-index">{note.noteNo}</span>
        <span className="cover-label">{note.coverLabel}</span>
        {usableImages.length > 1 ? (
          <>
            <button className="detail-gallery-arrow is-prev" type="button" onClick={(event) => changeImage(-1, event)} aria-label="上一张图片">‹</button>
            <button className="detail-gallery-arrow is-next" type="button" onClick={(event) => changeImage(1, event)} aria-label="下一张图片">›</button>
            <span className="detail-gallery-page" aria-live="polite">{safeIndex + 1} / {usableImages.length}</span>
            <span className="detail-gallery-hint" aria-hidden="true">滚轮 / 左右滑动</span>
          </>
        ) : null}
      </SwipeSurface>
      {usableImages.length > 1 ? (
        <div className="detail-gallery-thumbs" aria-label="笔记图片">
          {usableImages.map((source, index) => (
            <button
              key={source}
              type="button"
              className={"detail-thumb" + (index === safeIndex ? " is-active" : "")}
              onClick={() => setActiveIndex(index)}
              aria-label={"查看第" + (index + 1) + "张图片"}
            >
              <img src={assetUrl(source)} alt="" onError={() => markFailed(source)} />
            </button>
          ))}
        </div>
      ) : null}
    </div>
  );
}

function NoteCard({ note, selected, saved, onSelect, onToggleSaved }) {
  const handleKeyDown = (event) => {
    if (event.key === "Enter" || event.key === " ") {
      event.preventDefault();
      onSelect(note.id);
    }
  };

  return (
    <article
      className={"note-card" + (selected ? " is-selected" : "")}
      onClick={() => onSelect(note.id)}
      onKeyDown={handleKeyDown}
      role="button"
      tabIndex="0"
      aria-label={"打开笔记：" + note.title}
    >
      <NoteCardGallery note={note} />
      <div className="note-card-body">
        <div className="note-card-meta">
          <span className="category-dot" style={{ backgroundColor: note.categoryColor }}></span>
          <span>{note.category}</span>
          <span className="meta-separator">·</span>
          <span>{note.place}</span>
        </div>
        <h3>{note.title}</h3>
        <p>{note.excerpt}</p>
        <div className="note-card-footer">
          <span>{note.date}</span>
          <button
            className={"bookmark-button" + (saved ? " is-saved" : "")}
            type="button"
            aria-label={saved ? "取消收藏" : "收藏笔记"}
            onClick={(event) => {
              event.stopPropagation();
              onToggleSaved(note.id);
            }}
          >
            <Icon name="bookmark" size={16} strokeWidth={saved ? 2.4 : 1.7} />
          </button>
        </div>
      </div>
    </article>
  );
}

function DetailSheet({ note, saved, onClose, onToggleSaved, onSource }) {
  const paragraphs = String(note.body || note.excerpt || "").split("\n").filter(Boolean);
  const tags = Array.isArray(note.tags) ? note.tags : [];
  const dialogRef = useRef(null);
  const closeRef = useRef(null);
  const closeHandlerRef = useRef(onClose);
  const titleId = "note-title-" + note.id;
  const bodyId = "note-body-" + note.id;

  useEffect(() => {
    closeHandlerRef.current = onClose;
  }, [onClose]);

  useEffect(() => {
    const previousActiveElement = document.activeElement;
    const dialog = dialogRef.current;
    const focusSelector = "button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])";

    closeRef.current?.focus();

    const handleKeyDown = (event) => {
      if (event.key === "Escape") {
        event.preventDefault();
        closeHandlerRef.current();
        return;
      }
      if (event.key !== "Tab" || !dialog) return;
      const focusables = Array.from(dialog.querySelectorAll(focusSelector)).filter((element) => element.offsetParent !== null);
      if (!focusables.length) {
        event.preventDefault();
        dialog.focus();
        return;
      }
      const first = focusables[0];
      const last = focusables[focusables.length - 1];
      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    };

    document.addEventListener("keydown", handleKeyDown);
    return () => {
      document.removeEventListener("keydown", handleKeyDown);
      if (previousActiveElement && typeof previousActiveElement.focus === "function") previousActiveElement.focus();
    };
  }, []);

  return (
    <div className="sheet-backdrop" onClick={onClose}>
      <section
        ref={dialogRef}
        className="detail-sheet"
        onClick={(event) => event.stopPropagation()}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        aria-describedby={bodyId}
        tabIndex={-1}
      >
        <button ref={closeRef} className="sheet-close" type="button" onClick={onClose} aria-label="关闭笔记详情">
          <Icon name="close" size={20} />
        </button>
        <NoteGallery note={note} />
        <div className="detail-content">
          <div className="detail-original-label">笔记原文</div>
          <div className="detail-kicker">
            <span style={{ color: note.categoryColor }}>{note.category}</span>
            <span>{note.date}</span>
          </div>
          <h2 id={titleId}>{note.title}</h2>
          <div className="detail-place">
            <Icon name="pin" size={15} strokeWidth={1.7} />
            <span>{note.place}</span>
            <span className="approx-badge">{note.geoPrecision}</span>
          </div>
          {note.locationNote ? <p className="detail-location-note">{note.locationNote}</p> : null}
          <div id={bodyId} className="detail-body">
            {paragraphs.map((paragraph, index) => <p key={paragraph + index}>{paragraph}</p>)}
          </div>
          <div className="detail-tags">
            {tags.map((tag) => <span key={tag}>#{tag}</span>)}
          </div>
          <div className="detail-actions">
            <button className={"action-button primary" + (saved ? " is-saved" : "")} type="button" onClick={() => onToggleSaved(note.id)}>
              <Icon name="bookmark" size={17} strokeWidth={saved ? 2.4 : 1.8} />
              <span>{saved ? "已收藏" : "收藏这条"}</span>
            </button>
            <button className="action-button secondary" type="button" onClick={onSource}>
              <Icon name="external" size={16} />
              <span>{note.sourceLabel}</span>
            </button>
          </div>
        </div>
      </section>
    </div>
  );
}

function App() {
  const [activeCategory, setActiveCategory] = useState("all");
  const [searchQuery, setSearchQuery] = useState("");
  const [viewMode, setViewMode] = useState("map");
  const [selectedId, setSelectedId] = useState(null);
  const [savedIds, setSavedIds] = useState(() => {
    try {
      return JSON.parse(localStorage.getItem("fuzhou-map-saved") || "[]");
    } catch (error) {
      return [];
    }
  });
  const [toast, setToast] = useState("");
  const [mapState, setMapState] = useState("正在加载底图");
  const [expandedGroupKey, setExpandedGroupKey] = useState(null);
  const mapRef = useRef(null);
  const markersRef = useRef(null);

  const filteredNotes = useMemo(() => {
    const query = searchQuery.trim().toLowerCase();
    return notes.filter((note) => {
      const categoryMatch = activeCategory === "all" || note.category === activeCategory;
      const queryMatch = !query || [note.title, note.place, note.area, note.excerpt, note.tags.join(" ")].join(" ").toLowerCase().includes(query);
      return categoryMatch && queryMatch;
    });
  }, [activeCategory, searchQuery]);

  const mappableNotes = useMemo(() => filteredNotes.filter(hasCoordinates), [filteredNotes]);

  const markerGroups = useMemo(() => {
    const groups = new Map();
    mappableNotes.forEach((note) => {
      const key = Number(note.lat).toFixed(4) + "," + Number(note.lng).toFixed(4);
      const group = groups.get(key) || [];
      group.push(note);
      groups.set(key, group);
    });
    return Array.from(groups.entries()).map(([key, group]) => {
      const selected = group.find((note) => note.id === selectedId) || group[0];
      return { key, note: selected, notes: group, count: group.length, selected: group.some((note) => note.id === selectedId) };
    });
  }, [mappableNotes, selectedId]);

  const selectedNote = notes.find((note) => note.id === selectedId) || null;

  const showToast = useCallback((message) => {
    setToast(message);
    window.clearTimeout(window.__fuzhouToastTimer);
    window.__fuzhouToastTimer = window.setTimeout(() => setToast(""), 2600);
  }, []);

  const selectNote = useCallback((id) => {
    setSelectedId(id);
    setViewMode("map");
  }, []);

  const toggleSaved = useCallback((id) => {
    setSavedIds((current) => {
      const next = current.includes(id) ? current.filter((item) => item !== id) : current.concat(id);
      localStorage.setItem("fuzhou-map-saved", JSON.stringify(next));
      showToast(current.includes(id) ? "已取消收藏" : "已收藏到本地");
      return next;
    });
  }, [showToast]);

  useEffect(() => {
    if (!window.L) {
      setMapState("地图引擎未加载");
      return undefined;
    }

    const map = window.L.map("fuzhou-map", {
      zoomControl: false,
      attributionControl: true,
      minZoom: 11,
      maxZoom: 18
    }).setView([26.0745, 119.2965], 13);

    window.L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
      maxZoom: 19,
      attribution: "&copy; OpenStreetMap contributors"
    }).addTo(map);

    window.L.control.zoom({ position: "bottomright" }).addTo(map);
    const markerLayer = window.L.layerGroup().addTo(map);
    mapRef.current = map;
    markersRef.current = markerLayer;
    setMapState("OpenStreetMap · 在线底图");
    const mapElement = document.getElementById("fuzhou-map");
    const resizeObserver = window.ResizeObserver ? new ResizeObserver(() => map.invalidateSize()) : null;
    if (resizeObserver && mapElement) resizeObserver.observe(mapElement);
    window.setTimeout(() => map.invalidateSize(), 80);

    return () => {
      if (resizeObserver) resizeObserver.disconnect();
      map.remove();
      mapRef.current = null;
      markersRef.current = null;
    };
  }, []);

  useEffect(() => {
    if (!markersRef.current || !window.L) return;
    markersRef.current.clearLayers();
    markerGroups.forEach(({ key, note, notes: groupNotes, count, selected }) => {
      const isExpanded = count > 1 && expandedGroupKey === key;
      if (isExpanded) {
        const collapseMarker = window.L.marker([Number(note.lat), Number(note.lng)], {
          icon: markerIcon(note, false, "×", true),
          title: "收起同点笔记"
        });
        collapseMarker.bindTooltip(count + " 条笔记已展开 · 点击收起", {
          direction: "top",
          offset: [0, -30],
          opacity: .94
        });
        collapseMarker.on("click", () => setExpandedGroupKey(null));
        collapseMarker.addTo(markersRef.current);

        groupNotes.forEach((groupNote, index) => {
          const marker = window.L.marker(spreadCoordinate(note, index, count), {
            icon: markerIcon(groupNote, groupNote.id === selectedId, markerLabel(groupNote)),
            title: groupNote.title
          });
          marker.bindTooltip(groupNote.noteNo + " · " + groupNote.title + " · 近似展开位置", {
            direction: "top",
            offset: [0, -30],
            opacity: .94
          });
          marker.on("click", () => selectNote(groupNote.id));
          marker.addTo(markersRef.current);
        });
        return;
      }

      const marker = window.L.marker([Number(note.lat), Number(note.lng)], {
        icon: markerIcon(note, selected, count > 1 ? String(count) : markerLabel(note), count > 1),
        title: count > 1 ? count + " 条笔记" : note.title
      });
      marker.bindTooltip(count > 1 ? count + " 条笔记在此处 · 点击展开" : note.category + " · " + note.title + " · 点击打开", {
        direction: "top",
        offset: [0, -30],
        opacity: .94
      });
      marker.on("click", () => {
        if (count > 1) setExpandedGroupKey(key);
        else selectNote(note.id);
      });
      marker.addTo(markersRef.current);
    });
  }, [expandedGroupKey, markerGroups, selectedId, selectNote]);

  useEffect(() => {
    if (!selectedNote || !mapRef.current || !hasCoordinates(selectedNote)) return;
    mapRef.current.flyTo([Number(selectedNote.lat), Number(selectedNote.lng)], Math.max(mapRef.current.getZoom(), 14), {
      duration: 0.55
    });
  }, [selectedNote]);

  useEffect(() => {
    if (selectedId && !filteredNotes.some((note) => note.id === selectedId)) {
      setSelectedId(null);
    }
  }, [filteredNotes, selectedId]);

  const resetMap = () => {
    if (!mapRef.current) return;
    mapRef.current.flyTo([26.0745, 119.2965], 13, { duration: 0.6 });
    showToast("已回到福州中心");
  };

  const clearFilters = () => {
    setActiveCategory("all");
    setSearchQuery("");
    showToast("已显示全部笔记");
  };

  return (
    <div className="app-shell">
      <header className="topbar">
        <div className="brand-block">
          <div className="eyebrow"><span className="eyebrow-rule"></span>{profile.eyebrow} / FUZHOU</div>
          <h1>{profile.name}的福州<span className="title-mark">·</span></h1>
          <p>{profile.description}</p>
        </div>
        <div className="profile-stamp" aria-label="作者信息">
          <span className="stamp-character">上</span>
          <span className="stamp-copy"><strong>{profile.name}</strong><small>福州手记</small></span>
        </div>
      </header>

      <main className="main-frame">
        <section className={"map-panel" + (viewMode === "list" ? " mobile-hidden" : "")} aria-label="福州地图">
          <div id="fuzhou-map"></div>
          <div className="map-wash"></div>
          <div className="map-heading">
            <span className="map-heading-label">CITY NOTES</span>
            <span className="map-heading-title">在城市里，留下坐标</span>
          </div>
          <div className="map-coordinates">
            <span>26°04′ N</span>
            <span>119°18′ E</span>
          </div>
          <div className="map-status"><span className="status-light"></span>{mapState} · 近似落点 {mappableNotes.length}/{filteredNotes.length}</div>
          <button className="recenter-button" type="button" onClick={resetMap} aria-label="回到福州中心">
            <Icon name="compass" size={19} />
          </button>
          <div className="map-legend">
            <span className="legend-item"><i className="legend-mark legend-category-city"></i>城事</span>
            <span className="legend-item"><i className="legend-mark legend-category-place"></i>去处</span>
            <span className="legend-item"><i className="legend-mark legend-category-roam"></i>闲逛</span>
            <span className="legend-item"><i className="legend-mark legend-category-spirit"></i>神游</span>
            <span className="legend-cluster-explain"><b aria-hidden="true">3</b><span>同一落点 3 条 · 点击展开</span></span>
          </div>
        </section>

        <aside className={"notes-panel" + (viewMode === "map" ? " mobile-hidden" : "")} aria-label="笔记索引">
          <div className="panel-head">
            <div>
              <div className="panel-kicker">NOTE INDEX / 01</div>
              <h2>笔记索引</h2>
            </div>
            <div className="note-count"><strong>{filteredNotes.length}</strong><span>条笔记</span></div>
          </div>

          <label className="search-shell">
            <Icon name="search" size={18} />
            <input
              value={searchQuery}
              onChange={(event) => setSearchQuery(event.target.value)}
              placeholder="搜索地点或笔记"
              aria-label="搜索地点或笔记"
            />
            {searchQuery ? <button type="button" onClick={() => setSearchQuery("")} aria-label="清空搜索"><Icon name="close" size={15} /></button> : null}
          </label>

          <div className="category-row" role="tablist" aria-label="笔记分类">
            {categories.map((category) => (
              <button
                key={category.key}
                className={"category-chip" + (activeCategory === category.key ? " is-active" : "")}
                type="button"
                role="tab"
                aria-selected={activeCategory === category.key}
                onClick={() => setActiveCategory(category.key)}
              >
                {category.label}
              </button>
            ))}
          </div>

          <div className="results-line">
            <span>{searchQuery ? "搜索结果" : "最近留下的坐标"}</span>
            {(activeCategory !== "all" || searchQuery) ? <button type="button" onClick={clearFilters}>清除筛选</button> : <span className="results-note">小红书原文</span>}
          </div>

          <div className="note-list">
            {filteredNotes.length ? filteredNotes.map((note) => (
              <NoteCard
                key={note.id}
                note={note}
                selected={selectedId === note.id}
                saved={savedIds.includes(note.id)}
                onSelect={selectNote}
                onToggleSaved={toggleSaved}
              />
            )) : (
              <div className="empty-state">
                <Icon name="search" size={22} />
                <strong>没有找到这段福州</strong>
                <p>换个关键词，或者显示全部笔记。</p>
                <button type="button" onClick={clearFilters}>显示全部</button>
              </div>
            )}
          </div>

          <div className="panel-footer">
            <Icon name="layers" size={16} />
            <span>内容与坐标均可在 <code>data/notes.js</code> 中替换</span>
          </div>
        </aside>
      </main>

      <nav className="mobile-nav" aria-label="视图切换">
        <button className={viewMode === "map" ? "is-active" : ""} type="button" onClick={() => setViewMode("map")}>
          <Icon name="map" size={18} />
          <span>地图</span>
        </button>
        <button className={viewMode === "list" ? "is-active" : ""} type="button" onClick={() => setViewMode("list")}>
          <Icon name="list" size={18} />
          <span>笔记</span>
        </button>
      </nav>

      {selectedNote ? (
        <DetailSheet
          note={selectedNote}
          saved={savedIds.includes(selectedNote.id)}
          onClose={() => setSelectedId(null)}
          onToggleSaved={toggleSaved}
          onSource={() => {
            if (selectedNote.sourceUrl) {
              window.open(selectedNote.sourceUrl, "_blank", "noopener,noreferrer");
            } else {
              showToast("这条笔记暂未接入原文链接");
            }
          }}
        />
      ) : null}

      {toast ? <div className="toast" role="status">{toast}</div> : null}
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
