Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .codex/environments/environment.toml
Original file line number Diff line number Diff line change
@@ -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
'''
107 changes: 107 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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 に委ねています。
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
19 changes: 0 additions & 19 deletions pnpm-lock.yaml

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

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ onlyBuiltDependencies:
- msw
- sharp
- workerd
enableGlobalVirtualStore: true
34 changes: 24 additions & 10 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<StyleSpecification>(
'kartore:style',
osmLibertyMigrated,
{},
);
const { mapStyle, setMapStyle, isLoading } = useMapStyleStore({
adapter: localStorageMapStyleStoreAdapter,
initialStyle: osmLibertyMigrated,
});

const [selectedLayerId, setSelectedLayerId] = useState<string>(mapStyle.layers[4].id);
const [selectedLayerId, setSelectedLayerId] = useState<string>(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 (
<div className={'flex min-h-screen items-center justify-center text-sm text-gray-600'}>
Loading map style...
</div>
);
}

return (
<MapProvider>
<div className={'relative flex max-h-screen min-h-screen w-full flex-row overflow-hidden'}>
Expand Down
7 changes: 7 additions & 0 deletions src/stores/mapStyle/MapStyleStoreAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { StyleSpecification } from 'maplibre-gl';

export type MapStyleStoreAdapter = {
id: string;
load: () => Promise<StyleSpecification | null>;
save: (style: StyleSpecification) => Promise<void>;
};
3 changes: 3 additions & 0 deletions src/stores/mapStyle/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type { MapStyleStoreAdapter } from './MapStyleStoreAdapter.ts';
export { localStorageMapStyleStoreAdapter } from './localStorageMapStyleStoreAdapter.ts';
export { useMapStyleStore } from './useMapStyleStore.ts';
32 changes: 32 additions & 0 deletions src/stores/mapStyle/localStorageMapStyleStoreAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
45 changes: 45 additions & 0 deletions src/stores/mapStyle/localStorageMapStyleStoreAdapter.ts
Original file line number Diff line number Diff line change
@@ -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<PropertyKey, unknown> => {
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));
},
};
Loading
Loading