diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 0000000..1dd54f1 --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,13 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "kartore-react" + +[setup] +script = "" + +[setup.darwin] +script = ''' +cd "$CODEX_WORKTREE_PATH" +corepack enable +pnpm install +''' diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4b4dfd7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,107 @@ +# Kartore React Agent Notes + +## このアプリケーションについて + +Kartore WebUI は、MapLibre のスタイル JSON を地図上で確認しながら編集する React アプリケーションです。初期データとして `src/samples/osm-liberty.ts` の `osmLibertyMigrated` を読み込み、`localStorage` の `kartore:style` に保持します。 + +画面は大きく 3 つの領域で構成されています。 + +- 地図表示: `src/components/editor/MapPanel/MapPanel.tsx` +- レイヤー一覧と並び替え: `src/components/editor/NavigationPanel/NavigationPanel.tsx` +- 選択レイヤーのプロパティ編集: `src/components/editor/PropertiesPanel/PropertiesPanel.tsx` + +主なユーザー操作は、MapLibre スタイルのレイヤーを選択し、レイヤー順序、source/source-layer、zoom 範囲、filter、paint/layout プロパティ、Raw JSON を編集することです。編集結果は React state 経由で MapLibre の `mapStyle` に渡され、地図表示へ即時反映されます。 + +## 技術スタック + +- React 19 + TypeScript +- Vite +- Tailwind CSS v4 +- MapLibre GL / react-map-gl +- `@maplibre/maplibre-gl-style-spec` +- React Aria / React Stately 系の UI 補助 +- Monaco Editor / Shiki +- dnd-kit +- TanStack React Query +- Vitest / happy-dom +- oxlint / oxfmt +- pnpm + +## 重要なファイル + +- `src/App.tsx`: アプリ全体の状態管理。MapLibre スタイル、選択レイヤー、レイヤー順変更、プロパティ更新を接続する。 +- `src/main.tsx`: React ルートと `QueryClientProvider`。 +- `src/components/editor/MapPanel/MapPanel.tsx`: `id="backgroundMap"` の MapLibre インスタンスを表示する。 +- `src/components/editor/NavigationPanel/NavigationPanel.tsx`: dnd-kit で `mapStyle.layers` をドラッグ並び替えする。 +- `src/components/editor/PropertiesPanel/LayerPropertiesPanel/LayerPropertiesPanel.tsx`: レイヤー種別ごとのプロパティパネルへ分岐する。 +- `src/components/editor/PropertiesPanel/LayerPropertiesPanel/utils/LayerUtil/LayerUtil.ts`: レイヤー型ガード、プロパティ分割、`replaceLayerData`。 +- `src/components/editor/PropertiesPanel/LayerPropertiesPanel/common/RawDataProperties/RawDataProperties.tsx`: Monaco Editor によるレイヤー JSON 直接編集。 +- `src/components/common/FilterInputField`: MapLibre expression/filter 入力 UI 群。 +- `src/components/common`: 再利用 UI コンポーネント群。 +- `src/samples`: サンプルレイヤーと OSM Liberty スタイル。 + +## データ更新の流れ + +1. `App.tsx` が `useLocalStorage('kartore:style', osmLibertyMigrated)` でスタイルを保持する。 +2. レイヤー選択は `selectedLayerId` で管理する。 +3. 各プロパティパネルは `onChange(layer, group, key, value)` を呼ぶ。 +4. `replaceLayerData` が該当レイヤーを探し、`paint`、`layout`、`metadata`、ルートプロパティ、または `all` を更新する。 +5. 更新後の `mapStyle` が `MapPanel` に渡され、地図描画に反映される。 + +注意: `replaceLayerData` はスタイルオブジェクトを浅くコピーしますが、対象レイヤー内部は直接変更しています。周辺コードはこの前提で動いているため、更新処理を変更するときは MapLibre の再描画、React state の参照、テスト影響を確認してください。 + +## レイヤー対応状況 + +`LayerPropertiesPanel` は以下の MapLibre レイヤー種別に対応しています。 + +- `background` +- `fill` +- `line` +- `symbol` +- `raster` +- `circle` +- `heatmap` +- `hillshade` +- `fill-extrusion` + +新しいレイヤー種別やプロパティを追加する場合は、型ガード、分岐、専用パネル、必要なら Raw JSON schema の参照を追加してください。 + +## 開発コマンド + +- 依存関係: `pnpm install` +- 開発サーバー: `pnpm run dev` +- 型チェック付きビルド: `pnpm run build` +- Vite ビルドのみ: `pnpm run build-no-tscheck` +- 型チェック: `pnpm run typecheck` +- lint: `pnpm run lint` +- 自動 lint 修正: `pnpm run lint:fix` +- format: `pnpm run format` +- format check: `pnpm run format:check` +- unit test: `pnpm run test:unit` +- changed test: `pnpm run test:changed` +- Cloudflare Workers preview: `pnpm run preview` +- deploy: `pnpm run deploy` + +## コーディング規約と作業メモ + +- import alias は `~/*` が `src/*` を指します。 +- 既存コードでは `.ts` / `.tsx` 拡張子付き import が使われています。近いコードの書き方に合わせてください。 +- UI コンポーネントは `ComponentName/ComponentName.tsx` と `ComponentName/index.ts` の構成が基本です。`plopfile.js` に component/icon generator があります。 +- スタイルは Tailwind CSS の utility class が中心です。class の結合には `src/utils/tailwindUtil.ts` の `cn` を使います。 +- MapLibre の style spec 型を優先し、プロパティ名は style spec の正式名に合わせます。 +- 式やデータドリブンスタイルの場合、単純なフォーム UI で扱えない値は `TextField` または Raw JSON editor にフォールバックしている箇所があります。 +- `MapPanel` の map id は `backgroundMap` です。ズーム操作などは `useMap()` からこの id を参照します。 +- テストは現状 `LayerUtil` の型ガード中心です。共有ロジックや style 更新処理を変える場合は Vitest の追加を検討してください。 + +## 変更時の確認ポイント + +- `pnpm run typecheck` +- `pnpm run lint` +- `pnpm run test:unit` +- UI や MapLibre 表示に関わる変更では `pnpm run dev` で地図表示、レイヤー選択、プロパティ更新、レイヤー並び替えを確認する。 + +## 既知の実装上の特徴 + +- README は現状 `Kartore WebUI` と `wip` のみで、詳細な仕様はコードから読む必要があります。 +- アプリは現時点ではサンプルスタイルを編集するクライアントサイド UI で、永続化先は localStorage です。 +- MapLibre style の全プロパティを完全にフォーム化しているわけではなく、レイヤー種別ごとに主要項目を UI 化し、詳細編集は Raw JSON editor に委ねています。 diff --git a/package.json b/package.json index 469ca93..28eb509 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,7 @@ "react-dom": "19.2.7", "react-map-gl": "8.1.1", "react-stately": "3.47.0", - "shiki": "4.2.0", - "usehooks-ts": "3.1.1" + "shiki": "4.2.0" }, "devDependencies": { "@tailwindcss/vite": "4.3.0", @@ -67,5 +66,5 @@ "vitest": "4.1.8", "wrangler": "4.99.0" }, - "packageManager": "pnpm@11.5.3+sha512.7ac1c919341c213a34dc0d02afb7143c5c26ac26ee8c4782deea821b8ac64d2134a081fd8941dae6e29bbb48f58dfc2b7fbceeccc07cb2f09d219d342a4969ed" + "packageManager": "pnpm@11.6.0+sha512.9a36518224080c6fe5165afdcfe79bfa118c29be703f3f462b1e32efe1e98e47e8750b148e08286250aad4113cc7993ca413c4e2cd447752708c2ee5751bc95f" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 076bda9..4dc8f57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,9 +68,6 @@ importers: shiki: specifier: 4.2.0 version: 4.2.0 - usehooks-ts: - specifier: 3.1.1 - version: 3.1.1(react@19.2.7) devDependencies: '@tailwindcss/vite': specifier: 4.3.0 @@ -2466,9 +2463,6 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash.debounce@4.0.8: - resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} - lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -3097,12 +3091,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - usehooks-ts@3.1.1: - resolution: {integrity: sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==} - engines: {node: '>=16.15.0'} - peerDependencies: - react: ^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc - util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -5235,8 +5223,6 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash.debounce@4.0.8: {} - lodash.merge@4.6.2: {} log-symbols@4.1.0: @@ -5963,11 +5949,6 @@ snapshots: dependencies: react: 19.2.7 - usehooks-ts@3.1.1(react@19.2.7): - dependencies: - lodash.debounce: 4.0.8 - react: 19.2.7 - util-deprecate@1.0.2: {} v8flags@4.0.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6b10e11..16755fc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,3 +7,4 @@ onlyBuiltDependencies: - msw - sharp - workerd +enableGlobalVirtualStore: true diff --git a/src/App.tsx b/src/App.tsx index e8d8003..21159e8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,6 @@ import type { LayerSpecification, StyleSpecification } from 'maplibre-gl'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { MapProvider } from 'react-map-gl/maplibre'; -import { useLocalStorage } from 'usehooks-ts'; import { ControlPanel } from '~/components/editor/ControlPanel'; import { MapPanel } from '~/components/editor/MapPanel'; @@ -10,29 +9,44 @@ import type { onChangeType } from '~/components/editor/PropertiesPanel/LayerProp import { replaceLayerData } from '~/components/editor/PropertiesPanel/LayerPropertiesPanel/utils/LayerUtil/LayerUtil.ts'; import { PropertiesPanel } from '~/components/editor/PropertiesPanel/PropertiesPanel.tsx'; import { osmLibertyMigrated } from '~/samples/osm-liberty.ts'; +import { localStorageMapStyleStoreAdapter, useMapStyleStore } from '~/stores/mapStyle'; function App() { - const [mapStyle, setMapStyle] = useLocalStorage( - 'kartore:style', - osmLibertyMigrated, - {}, - ); + const { mapStyle, setMapStyle, isLoading } = useMapStyleStore({ + adapter: localStorageMapStyleStoreAdapter, + initialStyle: osmLibertyMigrated, + }); - const [selectedLayerId, setSelectedLayerId] = useState(mapStyle.layers[4].id); + const [selectedLayerId, setSelectedLayerId] = useState(osmLibertyMigrated.layers[4].id); const selectedLayer = mapStyle.layers.find((layer) => layer.id === selectedLayerId) ?? mapStyle.layers[0]; + + useEffect(() => { + if (!mapStyle.layers.some((layer) => layer.id === selectedLayerId)) { + setSelectedLayerId(mapStyle.layers[0].id); + } + }, [mapStyle.layers, selectedLayerId]); + const handleChangeLayerOrder = (layer: LayerSpecification[]) => { - setMapStyle((currentStyle) => { + setMapStyle((currentStyle: StyleSpecification) => { return { ...currentStyle, layers: layer }; }); }; const handleChangeLayerData: onChangeType = (layer, group, key, value) => { - setMapStyle((currentStyle) => { + setMapStyle((currentStyle: StyleSpecification) => { return replaceLayerData(currentStyle, layer, group, key, value); }); }; + if (isLoading) { + return ( +
+ Loading map style... +
+ ); + } + return (
diff --git a/src/stores/mapStyle/MapStyleStoreAdapter.ts b/src/stores/mapStyle/MapStyleStoreAdapter.ts new file mode 100644 index 0000000..1dd551f --- /dev/null +++ b/src/stores/mapStyle/MapStyleStoreAdapter.ts @@ -0,0 +1,7 @@ +import type { StyleSpecification } from 'maplibre-gl'; + +export type MapStyleStoreAdapter = { + id: string; + load: () => Promise; + save: (style: StyleSpecification) => Promise; +}; diff --git a/src/stores/mapStyle/index.ts b/src/stores/mapStyle/index.ts new file mode 100644 index 0000000..940a4d4 --- /dev/null +++ b/src/stores/mapStyle/index.ts @@ -0,0 +1,3 @@ +export type { MapStyleStoreAdapter } from './MapStyleStoreAdapter.ts'; +export { localStorageMapStyleStoreAdapter } from './localStorageMapStyleStoreAdapter.ts'; +export { useMapStyleStore } from './useMapStyleStore.ts'; diff --git a/src/stores/mapStyle/localStorageMapStyleStoreAdapter.test.ts b/src/stores/mapStyle/localStorageMapStyleStoreAdapter.test.ts new file mode 100644 index 0000000..eed8022 --- /dev/null +++ b/src/stores/mapStyle/localStorageMapStyleStoreAdapter.test.ts @@ -0,0 +1,32 @@ +import type { StyleSpecification } from 'maplibre-gl'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { osmLibertyMigrated } from '~/samples/osm-liberty.ts'; + +import { localStorageMapStyleStoreAdapter } from './localStorageMapStyleStoreAdapter.ts'; + +const STORAGE_KEY = 'kartore:style'; + +describe('localStorageMapStyleStoreAdapter', () => { + afterEach(() => { + localStorage.clear(); + }); + + it('returns null when no style is stored', async () => { + await expect(localStorageMapStyleStoreAdapter.load()).resolves.toBeNull(); + }); + + it('loads a saved style', async () => { + const style = { ...osmLibertyMigrated, name: 'Saved Style' } satisfies StyleSpecification; + + await localStorageMapStyleStoreAdapter.save(style); + + await expect(localStorageMapStyleStoreAdapter.load()).resolves.toEqual(style); + }); + + it('returns null when stored JSON is invalid', async () => { + localStorage.setItem(STORAGE_KEY, '{'); + + await expect(localStorageMapStyleStoreAdapter.load()).resolves.toBeNull(); + }); +}); diff --git a/src/stores/mapStyle/localStorageMapStyleStoreAdapter.ts b/src/stores/mapStyle/localStorageMapStyleStoreAdapter.ts new file mode 100644 index 0000000..51e770a --- /dev/null +++ b/src/stores/mapStyle/localStorageMapStyleStoreAdapter.ts @@ -0,0 +1,45 @@ +import type { StyleSpecification } from 'maplibre-gl'; + +import type { MapStyleStoreAdapter } from './MapStyleStoreAdapter.ts'; + +const STORAGE_KEY = 'kartore:style'; + +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value != null; +}; + +const isStyleSpecification = (value: unknown): value is StyleSpecification => { + if (!isRecord(value)) { + return false; + } + + return ( + typeof value.version === 'number' && isRecord(value.sources) && Array.isArray(value.layers) + ); +}; + +export const localStorageMapStyleStoreAdapter: MapStyleStoreAdapter = { + id: 'local-storage', + load: async () => { + const storedValue = localStorage.getItem(STORAGE_KEY); + + if (storedValue == null) { + return null; + } + + try { + const parsedValue: unknown = JSON.parse(storedValue); + + if (!isStyleSpecification(parsedValue)) { + return null; + } + + return parsedValue; + } catch { + return null; + } + }, + save: async (style) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(style)); + }, +}; diff --git a/src/stores/mapStyle/useMapStyleStore.test.tsx b/src/stores/mapStyle/useMapStyleStore.test.tsx new file mode 100644 index 0000000..21185d4 --- /dev/null +++ b/src/stores/mapStyle/useMapStyleStore.test.tsx @@ -0,0 +1,80 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { PropsWithChildren } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { osmLibertyMigrated } from '~/samples/osm-liberty.ts'; + +import type { MapStyleStoreAdapter } from './MapStyleStoreAdapter.ts'; +import { useMapStyleStore } from './useMapStyleStore.ts'; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + return ({ children }: PropsWithChildren) => ( + {children} + ); +}; + +describe('useMapStyleStore', () => { + it('reflects the loaded style in state', async () => { + const loadedStyle = { ...osmLibertyMigrated, name: 'Loaded Style' }; + const adapter: MapStyleStoreAdapter = { + id: 'loaded-style-test', + load: vi.fn(async () => loadedStyle), + save: vi.fn(async () => {}), + }; + + const { result } = renderHook( + () => useMapStyleStore({ adapter, initialStyle: osmLibertyMigrated }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => { + expect(result.current.mapStyle.name).toBe('Loaded Style'); + }); + }); + + it('saves the next style when setMapStyle receives an updater', async () => { + const save = vi.fn(async () => {}); + const adapter: MapStyleStoreAdapter = { + id: 'updater-save-test', + load: vi.fn(async () => null), + save, + }; + + const { result } = renderHook( + () => useMapStyleStore({ adapter, initialStyle: osmLibertyMigrated }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + act(() => { + result.current.setMapStyle((currentStyle) => ({ + ...currentStyle, + name: 'Updated Style', + })); + }); + + await waitFor(() => { + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Updated Style', + }), + ); + }); + expect(result.current.mapStyle.name).toBe('Updated Style'); + }); +}); diff --git a/src/stores/mapStyle/useMapStyleStore.ts b/src/stores/mapStyle/useMapStyleStore.ts new file mode 100644 index 0000000..f9984e8 --- /dev/null +++ b/src/stores/mapStyle/useMapStyleStore.ts @@ -0,0 +1,80 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import type { StyleSpecification } from 'maplibre-gl'; +import type { Dispatch, SetStateAction } from 'react'; +import { useCallback, useEffect, useState } from 'react'; + +import type { MapStyleStoreAdapter } from './MapStyleStoreAdapter.ts'; + +type UseMapStyleStoreOptions = { + adapter: MapStyleStoreAdapter; + initialStyle: StyleSpecification; +}; + +type UseMapStyleStoreResult = { + mapStyle: StyleSpecification; + setMapStyle: Dispatch>; + isLoading: boolean; + isSaving: boolean; + loadError: Error | null; + saveError: Error | null; +}; + +const resolveMapStyleUpdate = ( + value: SetStateAction, + currentStyle: StyleSpecification, +) => { + return typeof value === 'function' ? value(currentStyle) : value; +}; + +export const useMapStyleStore = ({ + adapter, + initialStyle, +}: UseMapStyleStoreOptions): UseMapStyleStoreResult => { + const [mapStyle, setMapStyleState] = useState(initialStyle); + + const { + data: loadedStyle, + error: loadError, + isLoading, + isSuccess, + } = useQuery({ + queryKey: ['mapStyle', adapter.id], + queryFn: () => adapter.load(), + }); + + const { + mutate: saveMapStyle, + error: saveError, + isPending: isSaving, + } = useMutation({ + mutationFn: (style: StyleSpecification) => adapter.save(style), + }); + + useEffect(() => { + if (isSuccess) { + setMapStyleState(loadedStyle ?? initialStyle); + } + }, [initialStyle, isSuccess, loadedStyle]); + + const setMapStyle = useCallback>>( + (value) => { + setMapStyleState((currentStyle) => { + const nextStyle = resolveMapStyleUpdate(value, currentStyle); + + saveMapStyle(nextStyle); + + return nextStyle; + }); + }, + [saveMapStyle], + ); + + return { + mapStyle, + setMapStyle, + isLoading, + isSaving, + loadError, + saveError, + }; +};