├── vite.config.js
├── .gitignore
├── package.json
├── index.html
├── src
├── utils.js
├── style.css
├── store.js
└── main.js
├── public
└── vite.svg
└── .github
└── workflows
└── deploy.yml
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 |
3 | export default defineConfig({
4 | base: '/cadastre/',
5 | });
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | .env
27 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cadastre",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "preview": "vite preview"
10 | },
11 | "devDependencies": {
12 | "vite": "^6.3.5"
13 | },
14 | "prettier": {
15 | "singleQuote": true
16 | },
17 | "dependencies": {
18 | "@maptiler/sdk": "^3.3.0",
19 | "@turf/turf": "^7.2.0",
20 | "prettier": "^3.5.3"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite App
8 |
9 |
10 |
11 |
12 |
Cliquez sur une parcelle pour afficher les rebords non constructibles ainsi que le détail cadastral.
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | export const encodeState = ({ state, origin = window.location }) => {
2 | const currentUrl = new URL(origin);
3 | const { center, zoom } = state.map;
4 | const { id: landId } = state.selectedLand;
5 | const { lng, lat } = center;
6 | const searchParams = new URLSearchParams(
7 | pickBy(
8 | {
9 | landId,
10 | lng,
11 | lat,
12 | zoom,
13 | },
14 | ([, value]) => Boolean(value),
15 | ),
16 | );
17 |
18 | currentUrl.search = searchParams.toString();
19 |
20 | return currentUrl;
21 | };
22 |
23 | export const decodeState = ({ url }) => {
24 | const searchParams = new URL(url).searchParams;
25 | return {
26 | map: {
27 | center: [
28 | searchParams.get('lng') ?? 5.6770271,
29 | searchParams.get('lat') ?? 43.52602,
30 | ],
31 | zoom: searchParams.get('zoom') ?? 17,
32 | },
33 | selectedLand: {
34 | id: searchParams.get('landId') ?? undefined,
35 | features: [],
36 | },
37 | highlightedLand: {
38 | id: undefined,
39 | },
40 | };
41 | };
42 |
43 | export const pickBy = (object, predicate) => {
44 | return Object.fromEntries(Object.entries(object).filter(predicate));
45 | };
46 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | # Simple workflow for deploying static content to GitHub Pages
2 | name: Deploy static content to Pages
3 |
4 | on:
5 | # Runs on pushes targeting the default branch
6 | push:
7 | branches: [ 'main' ]
8 |
9 | # Allows you to run this workflow manually from the Actions tab
10 | workflow_dispatch:
11 |
12 | # Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages
13 | permissions:
14 | contents: read
15 | pages: write
16 | id-token: write
17 |
18 | # Allow one concurrent deployment
19 | concurrency:
20 | group: 'pages'
21 | cancel-in-progress: true
22 |
23 | jobs:
24 | # Single deploy job since we're just deploying
25 | deploy:
26 | environment:
27 | name: github-pages
28 | url: ${{ steps.deployment.outputs.page_url }}
29 | runs-on: ubuntu-latest
30 | steps:
31 | - name: Checkout
32 | uses: actions/checkout@v4
33 | - name: Set up Node
34 | uses: actions/setup-node@v4
35 | with:
36 | node-version: lts/*
37 | cache: 'npm'
38 | - name: Install dependencies
39 | run: npm ci
40 | - name: Set environment variables
41 | run: |
42 | echo "VITE_MAP_TILER_API_KEY=${{ secrets.VITE_MAP_TILER_API_KEY }}" >> .env.production
43 | - name: Build
44 | run: npm run build
45 | - name: Setup Pages
46 | uses: actions/configure-pages@v5
47 | - name: Upload artifact
48 | uses: actions/upload-pages-artifact@v3
49 | with:
50 | # Upload dist folder
51 | path: './dist'
52 | - name: Deploy to GitHub Pages
53 | id: deployment
54 | uses: actions/deploy-pages@v4
--------------------------------------------------------------------------------
/src/style.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 | --background: #e4e3e3;
6 | --color: #2a2a2e;
7 |
8 | color-scheme: light dark;
9 | color: rgba(255, 255, 255, 0.87);
10 | font-synthesis: none;
11 | text-rendering: optimizeLegibility;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | }
15 |
16 | *,
17 | *::before,
18 | *::after {
19 | box-sizing: border-box;
20 | }
21 |
22 | * {
23 | margin: 0;
24 | }
25 |
26 | body {
27 | line-height: 1.4;
28 | margin: unset;
29 | -webkit-font-smoothing: antialiased;
30 | }
31 |
32 | button,
33 | input,
34 | textarea,
35 | select {
36 | font: inherit;
37 | }
38 |
39 | p, h1, h2, h3, h4, h5, h6 {
40 | overflow-wrap: break-word;
41 | }
42 |
43 | img,
44 | picture,
45 | svg,
46 | canvas {
47 | display: block;
48 | max-inline-size: 100%;
49 | block-size: auto;
50 | }
51 |
52 |
53 | #map {
54 | position: absolute;
55 | inset: 0;
56 | }
57 |
58 | #app {
59 | position: absolute;
60 | left: 1rem;
61 | top: 1rem;
62 | z-index: 1;
63 | color: var(--color);
64 | padding: 1em;
65 | width: 20em;
66 | border-radius: 0.5em;
67 | background: var(--background);
68 | backdrop-filter: blur(13px);
69 | -webkit-backdrop-filter: blur(13px);
70 | opacity: 0.9;
71 | border: 1px solid rgba(255,255,255,0.225);
72 | box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.2),
73 | 0 4px 5px 0 rgba(0, 0, 0, 0.14),
74 | 0 1px 10px 0 rgba(0, 0, 0, 0.12);
75 | }
76 |
77 | .description {
78 | font-size: 0.9em;
79 | }
80 |
81 |
82 | @media (prefers-color-scheme: dark) {
83 | :root {
84 | --color: #c1c1c5;
85 | --background: #151d3c;
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/store.js:
--------------------------------------------------------------------------------
1 | import { buffer, difference, featureCollection, union } from '@turf/turf';
2 |
3 | export const createStore = ({ initialState }) => {
4 | const eventTarget = new EventTarget();
5 | const _state = structuredClone(initialState);
6 | const state = Object.defineProperties(
7 | {},
8 | {
9 | selectedLand: {
10 | get() {
11 | const { id: landId, features = [] } = _state.selectedLand;
12 | return {
13 | id: landId,
14 | ringGeoJSON: getRingGeoJSON({ features }),
15 | properties: features.at(0)?.properties,
16 | };
17 | },
18 | enumerable: true,
19 | },
20 |
21 | highlightedLand: {
22 | get() {
23 | const {
24 | highlightedLand: { id: highlightedLandId },
25 | } = _state;
26 | return {
27 | landId: highlightedLandId,
28 | };
29 | },
30 | },
31 | map: {
32 | get() {
33 | return structuredClone(_state.map);
34 | },
35 | enumerable: true,
36 | },
37 | },
38 | );
39 |
40 | return {
41 | on(...args) {
42 | return eventTarget.addEventListener(...args);
43 | },
44 | off(...args) {
45 | return eventTarget.removeEventListener(...args);
46 | },
47 | selectLand({ landId, features }) {
48 | _state.selectedLand = {
49 | id: landId,
50 | features,
51 | };
52 |
53 | eventTarget.dispatchEvent(
54 | new CustomEvent('landSelected', {
55 | detail: {
56 | landId,
57 | },
58 | }),
59 | );
60 | },
61 | highlightLand({ landId: newLandId }) {
62 | const { id: oldLandId } = _state.highlightedLand;
63 | _state.highlightedLand = { id: newLandId };
64 | eventTarget.dispatchEvent(
65 | new CustomEvent('landHighlighted', {
66 | detail: {
67 | newLandId,
68 | oldLandId,
69 | },
70 | }),
71 | );
72 | },
73 | setCenter({ center, zoom }) {
74 | _state.map = {
75 | ..._state.map,
76 | center,
77 | zoom,
78 | };
79 | eventTarget.dispatchEvent(
80 | new CustomEvent('mapCenterChanged', {
81 | detail: {
82 | center,
83 | zoom,
84 | },
85 | }),
86 | );
87 | },
88 | getState() {
89 | return {
90 | ...state,
91 | };
92 | },
93 | };
94 | };
95 |
96 | function getRingGeoJSON({ features, borderSizeMeter = 4 }) {
97 | if (!features?.length) {
98 | return {
99 | type: 'FeatureCollection',
100 | features: [],
101 | };
102 | }
103 |
104 | const combinedPolygon =
105 | features.length >= 2 ? union(featureCollection(features)) : features.at(0);
106 |
107 | const insetPolygon = buffer(combinedPolygon, -borderSizeMeter, {
108 | units: 'meters',
109 | });
110 | const ringPolygon = insetPolygon
111 | ? difference(featureCollection([combinedPolygon, insetPolygon]))
112 | : combinedPolygon;
113 |
114 | return {
115 | type: 'FeatureCollection',
116 | features: [ringPolygon],
117 | };
118 | }
119 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | import './style.css';
2 | import '@maptiler/sdk/dist/maptiler-sdk.css';
3 | import * as maptilersdk from '@maptiler/sdk';
4 | import { createStore } from './store.js';
5 | import { decodeState, encodeState } from './utils.js';
6 |
7 | maptilersdk.config.apiKey = import.meta.env.VITE_MAP_TILER_API_KEY;
8 |
9 | const initialState = decodeState({ url: window.location.href });
10 | const store = createStore({
11 | initialState,
12 | });
13 | const { map: mapState } = store.getState();
14 |
15 | const map = new maptilersdk.Map({
16 | container: 'map', // container's id or the HTML element to render the map
17 | style: maptilersdk.MapStyle.HYBRID,
18 | ...mapState,
19 | });
20 |
21 | map.on('load', () => {
22 | map.addSource('cadastre', {
23 | type: 'geojson',
24 | data: '/cadastre/lands.json',
25 | promoteId: 'id',
26 | });
27 |
28 | map.addSource('limit-overlay', {
29 | type: 'geojson',
30 | data: {
31 | type: 'FeatureCollection',
32 | features: [],
33 | },
34 | });
35 |
36 | map.addLayer({
37 | id: 'lands-fill',
38 | type: 'fill',
39 | source: 'cadastre',
40 | paint: {
41 | 'fill-color': '#4b92d8',
42 | 'fill-opacity': [
43 | 'case',
44 | ['boolean', ['feature-state', 'hover'], false],
45 | 0.6,
46 | 0.3,
47 | ],
48 | },
49 | });
50 |
51 | map.addLayer({
52 | id: 'overlay-fill',
53 | type: 'fill',
54 | source: 'limit-overlay',
55 | paint: {
56 | 'fill-color': 'red',
57 | 'fill-opacity': 0.3,
58 | },
59 | });
60 |
61 | map.addLayer({
62 | id: 'lands-countours',
63 | type: 'line',
64 | source: 'cadastre',
65 | paint: {
66 | 'line-color': '#4b92d8',
67 | },
68 | });
69 |
70 | map.addLayer({
71 | id: 'labels',
72 | type: 'symbol',
73 | source: 'cadastre',
74 | minzoom: 18,
75 | layout: {
76 | 'text-field': ['concat', ['get', 'section'], '-', ['get', 'numero']],
77 | },
78 | paint: {
79 | 'text-color': '#000000',
80 | 'text-halo-color': '#ffffff',
81 | 'text-halo-width': 1,
82 | },
83 | });
84 |
85 | store.on('landSelected', (event) => {
86 | const { selectedLand } = store.getState();
87 | map.getSource('limit-overlay').setData(selectedLand.ringGeoJSON);
88 | });
89 |
90 | store.on('landHighlighted', ({ detail }) => {
91 | const canvas = map.getCanvas();
92 | const { oldLandId, newLandId } = detail;
93 |
94 | if (oldLandId) {
95 | map.setFeatureState(
96 | {
97 | source: 'cadastre',
98 | id: oldLandId,
99 | },
100 | {
101 | hover: false,
102 | },
103 | );
104 | map.getCanvas().style.cursor = '';
105 | }
106 |
107 | if (newLandId) {
108 | map.setFeatureState(
109 | {
110 | source: 'cadastre',
111 | id: newLandId,
112 | },
113 | {
114 | hover: true,
115 | },
116 | );
117 | canvas.style.cursor = 'pointer';
118 | }
119 | });
120 |
121 | map.on('click', 'lands-fill', (e) => {
122 | const landId = e.features?.at(0)?.properties?.id;
123 | const features = landId
124 | ? map.querySourceFeatures('cadastre', {
125 | filter: ['==', ['get', 'id'], landId],
126 | })
127 | : {
128 | type: 'FeatureCollection',
129 | features: [],
130 | };
131 |
132 | store.selectLand({ landId, features });
133 | });
134 |
135 | map.on('mousemove', 'lands-fill', (e) => {
136 | const landId = e.features?.at(0)?.id;
137 | store.highlightLand({ landId });
138 | });
139 |
140 | map.on('mouseleave', 'lands-fill', (e) => {
141 | store.highlightLand({
142 | landId: undefined,
143 | });
144 | });
145 |
146 | map.on('moveend', () => {
147 | store.setCenter({
148 | center: map.getCenter(),
149 | zoom: map.getZoom(),
150 | });
151 | });
152 |
153 | // todo hook to event instead
154 | setTimeout(() => {
155 | if (initialState?.selectedLand?.id) {
156 | const landId = initialState.selectedLand.id;
157 | store.selectLand({
158 | landId,
159 | features: map.querySourceFeatures('cadastre', {
160 | filter: ['==', ['get', 'id'], landId],
161 | }),
162 | });
163 | }
164 | }, 1_000);
165 | });
166 |
167 | store.on('mapCenterChanged', () => {
168 | const state = store.getState();
169 | window.history.replaceState(state, null, encodeState({ state }));
170 | });
171 |
172 | store.on('landSelected', () => {
173 | const state = store.getState();
174 | const { state: historyState } = history;
175 | const historyLandId = historyState?.selectedLand?.id;
176 | if (state?.selectedLand?.id !== historyLandId) {
177 | window.history.pushState(state, null, encodeState({ state })); // todo push store state ?
178 | }
179 | });
180 |
--------------------------------------------------------------------------------