├── 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 | --------------------------------------------------------------------------------