├── .github
└── workflows
│ └── deploy.yml
├── LICENSE
├── README.md
├── drop.png
├── eslint.config.js
├── index.html
├── package-lock.json
├── package.json
├── public
└── vite.svg
├── screenshot.png
├── src
├── App.tsx
├── FilterOverlay.tsx
├── assets
│ ├── react.svg
│ └── style.json
├── declarations.d.ts
├── index.css
├── main.tsx
└── vite-env.d.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to GitHub Pages
2 |
3 | on:
4 | push:
5 | branches:
6 | - main # Adjust if your default branch is different
7 |
8 | permissions:
9 | contents: write
10 |
11 | jobs:
12 | build-and-deploy:
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - name: Checkout repository
17 | uses: actions/checkout@v4
18 |
19 | - name: Setup Node.js
20 | uses: actions/setup-node@v4
21 | with:
22 | node-version: 20
23 |
24 | - name: Install dependencies
25 | run: npm ci
26 |
27 | - name: Build project
28 | run: npm run build
29 |
30 | - name: Deploy to GitHub Pages
31 | uses: JamesIves/github-pages-deploy-action@v4
32 | with:
33 | branch: gh-pages
34 | folder: dist # Ensure your build output goes to "dist"
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # csv-map-visualizer
2 |
3 | Visualize CSV data on a map.
4 |
5 | ## usage
6 |
7 | Go to https://wipfli.github.io/csv-map-visualizer/, drag and drop your CSV file, start analyzing.
8 |
9 |
10 |
11 | Your CSV file needs to provide latitude and longitude in columns called `lat` and `lon`.
12 |
13 | If a column contains text values, those will show up as "Categories". If a column has numeric values, it will show up in the "Numeric Ranges" filter panel. You can filter by text categories and numeric values.
14 |
15 | ## demo
16 |
17 | The city of Zürich publishes accident reports since 2011 as open-data. In the demo below we visualize this data. The screenshot shows bicyle accidents.
18 |
19 | Data: https://data.stadt-zuerich.ch/dataset/sid_dav_strassenverkehrsunfallorte (CC-BY)
20 |
21 | https://wipfli.github.io/csv-map-visualizer/?data=https://pub-cf7f11e26ace447db8f7215b61ac0eae.r2.dev/zurich-accident-reports-2011-to-2025.csv
22 |
23 |
25 |
26 | ## technology
27 |
28 | Uses DuckDB Wasm in the browser to analyze your CSV. No data is sent to a server.
29 |
--------------------------------------------------------------------------------
/drop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wipfli/csv-map-visualizer/4a98d70900c9cfc10d03cba0d5b069dbe01707f6/drop.png
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 |
7 | export default tseslint.config(
8 | { ignores: ['dist'] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ['**/*.{ts,tsx}'],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | 'react-hooks': reactHooks,
18 | 'react-refresh': reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | 'react-refresh/only-export-components': [
23 | 'warn',
24 | { allowConstantExport: true },
25 | ],
26 | },
27 | },
28 | )
29 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "app",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@duckdb/duckdb-wasm": "^1.29.0",
14 | "h3-js": "^4.1.0",
15 | "maplibre-gl": "^5.2.0",
16 | "pmtiles": "^4.3.0",
17 | "react": "^19.0.0",
18 | "react-dom": "^19.0.0"
19 | },
20 | "devDependencies": {
21 | "@eslint/js": "^9.21.0",
22 | "@types/react": "^19.0.10",
23 | "@types/react-dom": "^19.0.4",
24 | "@typescript-eslint/eslint-plugin": "^8.27.0",
25 | "@typescript-eslint/parser": "^8.27.0",
26 | "@vitejs/plugin-react": "^4.3.4",
27 | "autoprefixer": "^10.4.21",
28 | "eslint": "^9.21.0",
29 | "eslint-plugin-react-hooks": "^5.1.0",
30 | "eslint-plugin-react-refresh": "^0.4.19",
31 | "globals": "^15.15.0",
32 | "postcss": "^8.5.3",
33 | "typescript": "~5.7.2",
34 | "typescript-eslint": "^8.24.1",
35 | "vite": "^6.2.0"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wipfli/csv-map-visualizer/4a98d70900c9cfc10d03cba0d5b069dbe01707f6/screenshot.png
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useRef } from 'react';
2 | import * as duckdb from '@duckdb/duckdb-wasm';
3 | import { AsyncDuckDB, AsyncDuckDBConnection } from '@duckdb/duckdb-wasm';
4 | import maplibregl from 'maplibre-gl';
5 | import FilterOverlay from './FilterOverlay';
6 | import styleJson from "./assets/style.json";
7 | import { Protocol } from "pmtiles";
8 | import { cellToBoundary } from 'h3-js';
9 | import 'maplibre-gl/dist/maplibre-gl.css';
10 |
11 |
12 | type CategoryFiltersType = {
13 | [category: string]: string[];
14 | };
15 |
16 | type NumericFilterRangeType = {
17 | min: number | null;
18 | max: number | null;
19 | };
20 |
21 | type NumericFiltersType = {
22 | [field: string]: NumericFilterRangeType;
23 | };
24 |
25 | interface HoverData {
26 | count: number;
27 | totalCount: number;
28 | }
29 |
30 | const initialH3Resolution = 11;
31 | const initialOpacity = 1.0;
32 | const initialHeightMultiplier = 10.0;
33 |
34 | const h3CellToGeometry = (h3CellId: string) => {
35 | const boundary = cellToBoundary(h3CellId);
36 | const geoJsonPolygon = {
37 | type: "Polygon",
38 | coordinates: [
39 | boundary.map(([lat, lng]) => [lng, lat]),
40 | ],
41 | };
42 |
43 | return geoJsonPolygon;
44 | };
45 |
46 | const getBlueColor = (ratio: number): string => {
47 | const validRatio = Math.max(0, Math.min(1, ratio));
48 | const lightness = 90 - (validRatio * 60);
49 | return `hsl(210, 100%, ${lightness}%)`;
50 | };
51 |
52 | function addColorLegend(
53 | map: any,
54 | options: {
55 | title?: string;
56 | position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
57 | width?: number;
58 | height?: number;
59 | margin?: number;
60 | steps?: number;
61 | opacity?: number;
62 | } = {}
63 | ): void {
64 | const {
65 | title = 'Ratio',
66 | position = 'bottom-right',
67 | width = 200,
68 | height = 30,
69 | margin = 10,
70 | steps = 100,
71 | opacity = 0.9
72 | } = options;
73 |
74 | const legendContainer = document.createElement('div');
75 | legendContainer.className = 'maplibre-legend';
76 | legendContainer.style.position = 'absolute';
77 | legendContainer.style.zIndex = '10';
78 | legendContainer.style.background = 'white';
79 | legendContainer.style.padding = '10px';
80 | legendContainer.style.borderRadius = '4px';
81 | legendContainer.style.boxShadow = '0 1px 5px rgba(0, 0, 0, 0.2)';
82 |
83 | switch (position) {
84 | case 'top-left':
85 | legendContainer.style.top = `${margin}px`;
86 | legendContainer.style.left = `${margin}px`;
87 | break;
88 | case 'top-right':
89 | legendContainer.style.top = `${margin}px`;
90 | legendContainer.style.right = `${margin}px`;
91 | break;
92 | case 'bottom-left':
93 | legendContainer.style.bottom = `${margin}px`;
94 | legendContainer.style.left = `${margin}px`;
95 | break;
96 | case 'bottom-right':
97 | legendContainer.style.bottom = `${margin}px`;
98 | legendContainer.style.right = `${margin}px`;
99 | break;
100 | }
101 |
102 | if (title) {
103 | const titleElement = document.createElement('div');
104 | titleElement.style.fontWeight = 'bold';
105 | titleElement.style.marginBottom = '5px';
106 | titleElement.textContent = title;
107 | legendContainer.appendChild(titleElement);
108 | }
109 |
110 | const colorScale = document.createElement('div');
111 | colorScale.style.width = `${width}px`;
112 | colorScale.style.height = `${height}px`;
113 | colorScale.style.backgroundImage = createGradient(steps);
114 | colorScale.style.borderRadius = '2px';
115 | colorScale.style.opacity = opacity.toString();
116 | legendContainer.appendChild(colorScale);
117 |
118 | const labelsContainer = document.createElement('div');
119 | labelsContainer.style.display = 'flex';
120 | labelsContainer.style.justifyContent = 'space-between';
121 | labelsContainer.style.marginTop = '5px';
122 |
123 | const minLabel = document.createElement('div');
124 | minLabel.textContent = '0%';
125 | minLabel.style.fontSize = '12px';
126 |
127 | const maxLabel = document.createElement('div');
128 | maxLabel.textContent = '100%';
129 | maxLabel.style.fontSize = '12px';
130 |
131 | labelsContainer.appendChild(minLabel);
132 | labelsContainer.appendChild(maxLabel);
133 | legendContainer.appendChild(labelsContainer);
134 |
135 | map.getContainer().appendChild(legendContainer);
136 | }
137 |
138 | function createGradient(steps: number): string {
139 | const gradientStops: string[] = [];
140 |
141 | for (let i = 0; i <= steps; i++) {
142 | const ratio = i / steps;
143 | const color = getBlueColor(ratio);
144 | gradientStops.push(`${color} ${ratio * 100}%`);
145 | }
146 |
147 | return `linear-gradient(to right, ${gradientStops.join(', ')})`;
148 | }
149 |
150 | const App: React.FC = () => {
151 | const [db, setDb] = useState(null);
152 | const [dbConn, setDbConn] = useState(null);
153 | const [isDbReady, setIsDbReady] = useState(false);
154 | const [isLoading, setIsLoading] = useState(false);
155 | const [error, setError] = useState(null);
156 | const [tableName, setTableName] = useState('');
157 | const [isDragging, setIsDragging] = useState(false);
158 | const fileInputRef = useRef(null);
159 | const mapContainerRef = useRef(null);
160 | const mapRef = useRef(null);
161 |
162 | const [cols, setCols] = useState>({});
163 | const [categories, setCategories] = useState>({});
164 |
165 | const [showFilters, setShowFilters] = useState(false);
166 | const [categoryFilters, setCategoryFilters] = useState(categories);
167 |
168 | const [numericFilters, setNumericFilters] = useState({});
169 |
170 | const [, setActiveFilterCategories] = useState(categoryFilters);
171 | const [, setActiveNumericFilters] = useState(numericFilters);
172 |
173 | const [hoverData, setHoverData] = useState(null);
174 | const [dataUrl, setDataUrl] = useState(null);
175 |
176 | // Handle filter changes
177 | const handleFilterChange = (
178 | newCategoryFilters: CategoryFiltersType,
179 | newNumericFilters: NumericFiltersType,
180 | h3Resolution: number,
181 | opacity: number,
182 | heightMultiplier: number,
183 | ): void => {
184 | setActiveFilterCategories(newCategoryFilters);
185 | setActiveNumericFilters(newNumericFilters);
186 | applyFiltersToMap(newCategoryFilters, newNumericFilters, h3Resolution, opacity, heightMultiplier);
187 | };
188 |
189 | const applyFiltersToMap = (
190 | categoryFilterCriteria: CategoryFiltersType,
191 | numericFilterCriteria: NumericFiltersType,
192 | h3Resolution: number,
193 | opacity: number,
194 | heightMultiplier: number,
195 | ): void => {
196 |
197 | const query = async () => {
198 | if (!isDbReady || !db || !dbConn || !tableName) {
199 | setError('Database or table not ready.');
200 | return;
201 | }
202 |
203 | function generateSQLFilterStatement(
204 | categoryFilters: CategoryFiltersType,
205 | numericFilters: NumericFiltersType
206 | ): string {
207 | const conditions: string[] = [];
208 |
209 | for (const category in categoryFilters) {
210 | if (categoryFilters[category].length > 0) {
211 | const quotedValues = categoryFilters[category].map(value => `'${value}'`).join(', ');
212 | conditions.push(`${category} IN (${quotedValues})`);
213 | }
214 | }
215 |
216 | for (const field in numericFilters) {
217 | const { min, max } = numericFilters[field];
218 |
219 | if (min !== null) {
220 | conditions.push(`${field} >= ${min}`);
221 | }
222 |
223 | if (max !== null) {
224 | conditions.push(`${field} <= ${max}`);
225 | }
226 | }
227 |
228 | if (conditions.length === 0) {
229 | return "";
230 | }
231 |
232 | return `FILTER (WHERE ${conditions.join(' AND ')})`;
233 | }
234 |
235 | const filtersString = generateSQLFilterStatement(categoryFilterCriteria, numericFilterCriteria);
236 |
237 | const sql = `
238 | INSTALL h3 FROM community;
239 | LOAD h3;
240 |
241 | SELECT
242 | h3_latlng_to_cell(lat, lon, ${h3Resolution}) AS h3_cell,
243 | COUNT(*) ${filtersString} AS count,
244 | COUNT(*) AS total_count
245 | FROM ${tableName}
246 | GROUP BY h3_cell
247 | ORDER BY total_count DESC;
248 | `;
249 |
250 | const result = await dbConn.query(sql);
251 |
252 | const featureCollection = {
253 | 'type': 'FeatureCollection',
254 | 'features': [] as any
255 | }
256 |
257 | for (let i = 0; i < result.numRows; i++) {
258 | const h3Cell = result.getChildAt(0)?.get(i).toString(16);
259 | const count = Number(result.getChildAt(1)?.get(i));
260 | const totalCount = Number(result.getChildAt(2)?.get(i));
261 |
262 | const feature = {
263 | 'type': 'Feature',
264 | 'properties': {
265 | 'count': count,
266 | 'totalCount': totalCount,
267 | 'color': getBlueColor(count / totalCount),
268 | 'h3Cell': h3Cell
269 | },
270 | 'geometry': h3CellToGeometry(h3Cell)
271 | }
272 | featureCollection['features'].push(feature);
273 | }
274 |
275 | const coordinates = featureCollection.features.flatMap((feature: any) => {
276 | return feature.geometry.type === "Point"
277 | ? [feature.geometry.coordinates]
278 | : feature.geometry.coordinates[0];
279 | });
280 |
281 | const map = mapRef.current;
282 | if (map) {
283 | if (!map.getSource('h3')) {
284 | map.addSource('h3', {
285 | type: 'geojson',
286 | data: featureCollection as any
287 | })
288 | }
289 | else {
290 | const source: any = map.getSource('h3');
291 | source.setData(featureCollection);
292 | }
293 |
294 | if (!showFilters) {
295 | const bounds = coordinates.reduce(
296 | (bounds: maplibregl.LngLatBounds, coord: [number, number]) => {
297 | return bounds.extend(coord);
298 | },
299 | new maplibregl.LngLatBounds()
300 | );
301 | map.fitBounds(bounds, { padding: 50 });
302 | }
303 | const layers: string[] = ["h3", "h3-hover"];
304 | for (const layer of layers) {
305 | map.setPaintProperty(layer, 'fill-extrusion-opacity', opacity);
306 | map.setPaintProperty(layer, 'fill-extrusion-height', ['*', heightMultiplier, ['get', 'count']]);
307 | }
308 | }
309 |
310 | setShowFilters(true);
311 | }
312 | query();
313 | };
314 |
315 | const toggleFilters = (): void => {
316 | setShowFilters(!showFilters);
317 | };
318 |
319 | // Parse URL parameters
320 | useEffect(() => {
321 | const params = new URLSearchParams(window.location.search);
322 | const urlData = params.get('data');
323 | if (urlData) {
324 | setDataUrl(urlData);
325 | }
326 | }, []);
327 |
328 | // Initialize DuckDB and create a persistent connection
329 | useEffect(() => {
330 | const initDb = async () => {
331 | try {
332 | setIsLoading(true);
333 |
334 | const CDN_BUNDLES = duckdb.getJsDelivrBundles();
335 | const bundle = await duckdb.selectBundle(CDN_BUNDLES);
336 | const worker_url = URL.createObjectURL(
337 | new Blob([`importScripts("${bundle.mainWorker}");`], {
338 | type: "text/javascript"
339 | })
340 | );
341 |
342 | const worker = new Worker(worker_url);
343 | const logger = new duckdb.ConsoleLogger("DEBUG" as any);
344 | const database = new duckdb.AsyncDuckDB(logger, worker);
345 |
346 | await database.instantiate(bundle.mainModule, bundle.pthreadWorker);
347 | URL.revokeObjectURL(worker_url);
348 |
349 | // Create a persistent connection
350 | const connection = await database.connect();
351 |
352 | setDb(database);
353 | setDbConn(connection);
354 | setIsDbReady(true);
355 | setIsLoading(false);
356 | } catch (err) {
357 | console.error('Failed to initialize DuckDB:', err);
358 | setError(`Failed to initialize DuckDB: ${err instanceof Error ? err.message : String(err)}`);
359 | setIsLoading(false);
360 | }
361 | };
362 |
363 | initDb();
364 |
365 | return () => {
366 | // Close the connection and terminate the database when the component unmounts
367 | if (dbConn) {
368 | dbConn.close().catch(err => console.error('Error closing connection:', err));
369 | }
370 | if (db) {
371 | db.terminate();
372 | }
373 | };
374 | }, []);
375 |
376 | useEffect(() => {
377 | const getCsv = async () => {
378 | if (dataUrl && isDbReady) {
379 | await fetchCsvFromUrl(dataUrl);
380 | }
381 | };
382 | getCsv();
383 | }, [isDbReady, dataUrl]);
384 |
385 | useEffect(() => {
386 | if (mapContainerRef.current && !mapRef.current) {
387 | let protocol = new Protocol();
388 | maplibregl.addProtocol("pmtiles", protocol.tile);
389 |
390 | mapRef.current = new maplibregl.Map({
391 | container: mapContainerRef.current,
392 | style: styleJson as any,
393 | center: [8, 47],
394 | zoom: 5,
395 | hash: 'map'
396 | });
397 |
398 | const map = mapRef.current;
399 | if (!map) {
400 | return;
401 | }
402 |
403 | mapRef.current.on('load', function () {
404 | if (!map.getSource('h3')) {
405 | map.addSource('h3', {
406 | type: 'geojson',
407 | data: {
408 | type: 'FeatureCollection',
409 | features: []
410 | }
411 | })
412 | }
413 | map.addLayer({
414 | id: 'h3',
415 | source: 'h3',
416 | type: 'fill-extrusion',
417 | paint: {
418 | 'fill-extrusion-color': ['get', 'color'],
419 | 'fill-extrusion-height': ['*', initialHeightMultiplier, ['get', 'count']]
420 | }
421 | })
422 |
423 | if (!map.getSource('h3-hover')) {
424 | map.addSource('h3-hover', {
425 | type: 'geojson',
426 | data: {
427 | type: 'FeatureCollection',
428 | features: []
429 | }
430 | })
431 | }
432 | map.addLayer({
433 | id: 'h3-hover',
434 | source: 'h3-hover',
435 | type: 'fill-extrusion',
436 | paint: {
437 | 'fill-extrusion-color': '#FF9900',
438 | 'fill-extrusion-height': ['*', initialHeightMultiplier, ['get', 'count']]
439 | }
440 | });
441 |
442 | addColorLegend(map, {
443 | title: 'Percentage of cell total',
444 | position: 'bottom-left',
445 | width: 250,
446 | height: 20
447 | });
448 |
449 | map.setVerticalFieldOfView(10);
450 |
451 | (window as any).myMap = map;
452 | });
453 |
454 | map.on('mousemove', 'h3', (e) => {
455 | if (e.features && e.features.length > 0) {
456 | map.getCanvas().style.cursor = 'pointer';
457 | const feature = e.features[0];
458 | setHoverData({
459 | count: feature.properties.count,
460 | totalCount: feature.properties.totalCount,
461 | });
462 | const source: any = map.getSource('h3-hover');
463 | if (source) {
464 | source.setData({
465 | 'type': 'FeatureCollection',
466 | 'features': [
467 | {
468 | 'type': 'Feature',
469 | 'properties': {
470 | count: feature.properties.count,
471 | totalCount: feature.properties.totalCount,
472 | },
473 | 'geometry': h3CellToGeometry(feature.properties.h3Cell),
474 | }
475 | ]
476 | });
477 | }
478 | }
479 | });
480 |
481 | map.on('mouseleave', 'h3', () => {
482 | map.getCanvas().style.cursor = '';
483 | const source: any = map.getSource('h3-hover');
484 | if (source) {
485 | source.setData({
486 | 'type': 'FeatureCollection',
487 | 'features': []
488 | });
489 | }
490 | });
491 | }
492 | }, [mapContainerRef.current]);
493 |
494 | useEffect(() => {
495 | if (Object.keys(cols).length === 0 || !dbConn) {
496 | return;
497 | }
498 |
499 | const fetchCategories = async () => {
500 | if (!isDbReady || !dbConn || !tableName) {
501 | setError('Database or table not ready.');
502 | return;
503 | }
504 |
505 | const categoriesTmp: Record = {};
506 | for (const colName in cols) {
507 | if (cols[colName] !== 'Utf8') {
508 | continue;
509 | }
510 | const sql = `
511 | SELECT DISTINCT ${colName} FROM ${tableName};
512 | `;
513 | const result = await dbConn.query(sql);
514 | categoriesTmp[colName] = [];
515 | for (let i = 0; i < result.numRows; i++) {
516 | categoriesTmp[colName].push(result.getChildAt(0)?.get(i));
517 | }
518 | }
519 | setCategories(categoriesTmp);
520 | };
521 |
522 | fetchCategories();
523 | }, [cols, dbConn, isDbReady, tableName]);
524 |
525 | useEffect(() => {
526 | if (Object.keys(categories).length === 0) {
527 | return;
528 | }
529 | setCategoryFilters(categories);
530 | const numericFiltersTmp: NumericFiltersType = {};
531 | for (const col in cols) {
532 | if (['Int64', 'Float64'].includes(cols[col])) {
533 | const numericFilter: NumericFilterRangeType = {
534 | min: null,
535 | max: null
536 | };
537 | numericFiltersTmp[col] = numericFilter;
538 | }
539 | }
540 | setNumericFilters(numericFiltersTmp);
541 | applyFiltersToMap(categories, numericFiltersTmp, initialH3Resolution, initialOpacity, initialHeightMultiplier);
542 | }, [categories]);
543 |
544 | const handleDrop = async (e: React.DragEvent) => {
545 | e.preventDefault();
546 | setIsDragging(false);
547 |
548 | if (!isDbReady || !db || !dbConn) {
549 | setError('Database not yet initialized. Please wait.');
550 | return;
551 | }
552 |
553 | const files = e.dataTransfer.files;
554 | if (files.length === 0) return;
555 |
556 | const file = files[0];
557 | if (!file.name.endsWith('.csv')) {
558 | setError('Please upload a CSV file.');
559 | return;
560 | }
561 |
562 | await processFile(file);
563 | };
564 |
565 | const handleFileSelect = async (e: React.ChangeEvent) => {
566 | if (!isDbReady || !db || !dbConn) {
567 | setError('Database not yet initialized. Please wait.');
568 | return;
569 | }
570 |
571 | const files = e.target.files;
572 | if (!files || files.length === 0) return;
573 |
574 | const file = files[0];
575 | if (!file.name.endsWith('.csv')) {
576 | setError('Please upload a CSV file.');
577 | return;
578 | }
579 |
580 | await processFile(file);
581 | };
582 |
583 | const fetchCsvFromUrl = async (url: string) => {
584 | try {
585 | setIsLoading(true);
586 | setError(null);
587 |
588 | // Fetch the CSV file from the URL
589 | const response = await fetch(url);
590 | if (!response.ok) {
591 | throw new Error(`Failed to fetch CSV: ${response.status} ${response.statusText}`);
592 | }
593 |
594 | const csvData = await response.blob();
595 |
596 | // Create a File object from the Blob
597 | const fileName = url.split('/').pop() || 'data.csv';
598 | const file = new File([csvData], fileName, { type: 'text/csv' });
599 |
600 | // Process the file
601 | await processFile(file);
602 | } catch (err) {
603 | console.error('Error fetching CSV from URL:', err);
604 | setError(`Error fetching CSV from URL: ${err instanceof Error ? err.message : String(err)}`);
605 | setIsLoading(false);
606 | }
607 | };
608 |
609 | const processFile = async (file: File) => {
610 | try {
611 | setIsLoading(true);
612 | setError(null);
613 |
614 | if (db && dbConn) {
615 | const fileName = file.name.replace('.csv', '').replace(/[^a-zA-Z0-9]/g, '_');
616 | const generatedTableName = `csv_${fileName}_${Date.now()}`;
617 |
618 | const fileBuffer = await file.arrayBuffer();
619 | await db.registerFileBuffer(file.name, new Uint8Array(fileBuffer));
620 |
621 | await dbConn.query(`
622 | CREATE TABLE ${generatedTableName} AS
623 | SELECT * FROM read_csv_auto('${file.name}')
624 | `);
625 |
626 | const columnsResult = await dbConn.query(`PRAGMA table_info(${generatedTableName})`);
627 | const columns: string[] = [];
628 |
629 | const numColumnsRows = columnsResult.numRows;
630 | for (let i = 0; i < numColumnsRows; i++) {
631 | const columnName = columnsResult.getChildAt(1)?.get(i);
632 | if (typeof columnName === 'string') {
633 | columns.push(columnName.toLowerCase());
634 | }
635 | }
636 |
637 | let latColumn = '';
638 | let lonColumn = '';
639 |
640 | for (const col of columns) {
641 | if (col === 'lat') {
642 | latColumn = col;
643 | }
644 | if (col === 'lon') {
645 | lonColumn = col;
646 | }
647 | }
648 |
649 | if (!latColumn || !lonColumn) {
650 | setError('Could not find lat and lon columns in the CSV file. Please make sure your CSV contains "lat" and "lon" columns.');
651 | setIsLoading(false);
652 | return;
653 | }
654 |
655 | const result = await dbConn.query(`SELECT * FROM ${generatedTableName}`);
656 |
657 | setTableName(generatedTableName);
658 | setCols(Object.fromEntries(
659 | result.schema.fields
660 | .filter(field => !['lat', 'lon'].includes(field.name))
661 | .map(field => [field.name, String(field.type)])
662 | ));
663 |
664 | setIsLoading(false);
665 | }
666 | } catch (err) {
667 | console.error('Error processing CSV file:', err);
668 | setError(`Error processing CSV file: ${err instanceof Error ? err.message : String(err)}`);
669 | setIsLoading(false);
670 | }
671 | };
672 |
673 | const handleDropZoneClick = () => {
674 | if (fileInputRef.current) {
675 | fileInputRef.current.click();
676 | }
677 | };
678 |
679 | return (
680 |
681 |
CSV Map Visualizer
682 |
683 | {error && (
684 |
685 | {error}
686 |
687 | )}
688 |
689 | {!tableName && !isLoading && !dataUrl && (
690 |
{
694 | e.preventDefault();
695 | setIsDragging(true);
696 | }}
697 | onDragLeave={() => setIsDragging(false)}
698 | onDrop={handleDrop}
699 | >
700 |
707 |
Drag & drop a CSV file here, or click to select
708 |
Please ensure your CSV file has latitude and longitude columns (named lat and lon).
709 |
You can also load a CSV by adding
710 |
?data=https://example.com/my-file.csv to the URL.
711 |
712 | )}
713 |
714 | {isLoading && (
715 |
718 | )}
719 |
720 |
730 |
731 |
732 | {hoverData && (
733 | <>
734 |
735 | Hover Details
736 | setHoverData(null)}>×
737 |
738 |
739 |
740 | Count (Height):
741 | {hoverData.count}
742 |
743 |
744 | Total Count:
745 | {hoverData.totalCount}
746 |
747 |
748 | Ratio:
749 | {(hoverData.count / hoverData.totalCount * 100).toFixed(2)}%
750 |
751 |
752 | >
753 | )}
754 |
755 |
756 |
762 |
763 |
764 | );
765 | };
766 |
767 | export default App;
--------------------------------------------------------------------------------
/src/FilterOverlay.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 |
3 | type CategoryFiltersType = {
4 | [category: string]: string[];
5 | };
6 |
7 | type NumericFilterRangeType = {
8 | min: number | null;
9 | max: number | null;
10 | };
11 |
12 | type NumericFiltersType = {
13 | [field: string]: NumericFilterRangeType;
14 | };
15 |
16 | type SelectedFiltersType = {
17 | [category: string]: {
18 | [value: string]: boolean;
19 | };
20 | };
21 |
22 | type SelectedNumericFiltersType = {
23 | [field: string]: {
24 | min: string;
25 | max: string;
26 | };
27 | };
28 |
29 | interface FilterOverlayProps {
30 | categoryFilters: CategoryFiltersType;
31 | numericFilters: NumericFiltersType;
32 | onFilterChange: (
33 | categoryFilters: CategoryFiltersType,
34 | numericFilters: NumericFiltersType,
35 | h3Resolution: number,
36 | opacity: number,
37 | heightMultiplier: number
38 | ) => void;
39 | isOpen: boolean;
40 | onToggle: () => void;
41 | initialH3Resolution: number;
42 | initialOpacity: number;
43 | initialHeightMultiplier: number;
44 | }
45 |
46 | const FilterOverlay: React.FC = ({
47 | categoryFilters,
48 | numericFilters,
49 | onFilterChange,
50 | isOpen,
51 | onToggle,
52 | initialH3Resolution,
53 | initialOpacity,
54 | initialHeightMultiplier,
55 | }) => {
56 | const [selectedCategoryFilters, setSelectedCategoryFilters] = useState({});
57 | const [selectedNumericFilters, setSelectedNumericFilters] = useState({});
58 | const [h3Resolution, setH3Resolution] = useState(String(initialH3Resolution));
59 | const [opacity, setOpacity] = useState(initialOpacity);
60 | const [heightMultiplier, setHeightMultiplier] = useState(String(initialHeightMultiplier));
61 | const [h3Error, setH3Error] = useState('');
62 | const [heightMultiplierError, setHeightMultiplierError] = useState('');
63 |
64 | // Initialize selected category filters
65 | useEffect(() => {
66 | const initialFilters: SelectedFiltersType = {};
67 |
68 | Object.keys(categoryFilters).forEach(category => {
69 | initialFilters[category] = {};
70 | categoryFilters[category].forEach(value => {
71 | initialFilters[category][value] = true; // Default all to selected
72 | });
73 | });
74 |
75 | setSelectedCategoryFilters(initialFilters);
76 | }, [categoryFilters]);
77 |
78 | // Initialize selected numeric filters
79 | useEffect(() => {
80 | const initialNumericFilters: SelectedNumericFiltersType = {};
81 |
82 | Object.keys(numericFilters).forEach(field => {
83 | initialNumericFilters[field] = {
84 | min: numericFilters[field].min !== null ? String(numericFilters[field].min) : '',
85 | max: numericFilters[field].max !== null ? String(numericFilters[field].max) : ''
86 | };
87 | });
88 |
89 | setSelectedNumericFilters(initialNumericFilters);
90 | }, [numericFilters]);
91 |
92 | // Initialize h3Resolution from props
93 | useEffect(() => {
94 | if (initialH3Resolution !== null) {
95 | setH3Resolution(String(initialH3Resolution));
96 | }
97 | }, [initialH3Resolution]);
98 |
99 | // Initialize opacity from props
100 | useEffect(() => {
101 | setOpacity(initialOpacity);
102 | }, [initialOpacity]);
103 |
104 | // Initialize heightMultiplier from props
105 | useEffect(() => {
106 | if (initialHeightMultiplier !== null) {
107 | setHeightMultiplier(String(initialHeightMultiplier));
108 | }
109 | }, [initialHeightMultiplier]);
110 |
111 | // Handle checkbox changes
112 | const handleCheckboxChange = (category: string, value: string): void => {
113 | setSelectedCategoryFilters(prev => ({
114 | ...prev,
115 | [category]: {
116 | ...prev[category],
117 | [value]: !prev[category][value]
118 | }
119 | }));
120 | };
121 |
122 | // Handle numeric input changes
123 | const handleNumericInputChange = (
124 | field: string,
125 | type: 'min' | 'max',
126 | value: string
127 | ): void => {
128 | // Only allow numeric values and empty string
129 | if (value === '' || /^-?\d*\.?\d*$/.test(value)) {
130 | setSelectedNumericFilters(prev => ({
131 | ...prev,
132 | [field]: {
133 | ...prev[field],
134 | [type]: value
135 | }
136 | }));
137 | }
138 | };
139 |
140 | // Handle H3 resolution input change
141 | const handleH3ResolutionChange = (value: string): void => {
142 | // Only allow integer values between 1 and 15
143 | if (value === '' || /^\d+$/.test(value)) {
144 | setH3Resolution(value);
145 |
146 | // Validate if entered
147 | if (value !== '') {
148 | const numValue = parseInt(value, 10);
149 | if (numValue < 1 || numValue > 15) {
150 | setH3Error('Resolution must be between 1 and 15');
151 | } else {
152 | setH3Error('');
153 | }
154 | } else {
155 | setH3Error('');
156 | }
157 | }
158 | };
159 |
160 | // Handle height multiplier input change
161 | const handleHeightMultiplierChange = (value: string): void => {
162 | // Only allow non-negative float values
163 | if (value === '' || /^\d*\.?\d*$/.test(value)) {
164 | setHeightMultiplier(value);
165 |
166 | // Validate if entered
167 | if (value !== '') {
168 | const numValue = parseFloat(value);
169 | if (numValue < 0) {
170 | setHeightMultiplierError('Height multiplier must be greater than or equal to 0');
171 | } else {
172 | setHeightMultiplierError('');
173 | }
174 | } else {
175 | setHeightMultiplierError('');
176 | }
177 | }
178 | };
179 |
180 | // Handle opacity slider change
181 | const handleOpacityChange = (event: React.ChangeEvent): void => {
182 | const value = parseFloat(event.target.value);
183 | setOpacity(value);
184 | };
185 |
186 | // Apply filters
187 | const applyFilters = (): void => {
188 | // Validate H3 resolution and heightMultiplier
189 | if ((h3Resolution !== '' && h3Error) || (heightMultiplier !== '' && heightMultiplierError)) {
190 | return; // Don't apply if there's an error
191 | }
192 |
193 | // Process category filters
194 | const activeCategoryFilters: CategoryFiltersType = {};
195 |
196 | Object.keys(selectedCategoryFilters).forEach(category => {
197 | activeCategoryFilters[category] = [];
198 |
199 | Object.keys(selectedCategoryFilters[category]).forEach(value => {
200 | if (selectedCategoryFilters[category][value]) {
201 | activeCategoryFilters[category].push(value);
202 | }
203 | });
204 | });
205 |
206 | // Process numeric filters
207 | const activeNumericFilters: NumericFiltersType = {};
208 |
209 | Object.keys(selectedNumericFilters).forEach(field => {
210 | const minValue = selectedNumericFilters[field].min === ''
211 | ? null
212 | : parseFloat(selectedNumericFilters[field].min);
213 |
214 | const maxValue = selectedNumericFilters[field].max === ''
215 | ? null
216 | : parseFloat(selectedNumericFilters[field].max);
217 |
218 | activeNumericFilters[field] = {
219 | min: minValue,
220 | max: maxValue
221 | };
222 | });
223 |
224 | onFilterChange(
225 | activeCategoryFilters,
226 | activeNumericFilters,
227 | parseInt(h3Resolution, 10),
228 | opacity,
229 | parseFloat(heightMultiplier) || 0,
230 | );
231 | };
232 |
233 | // Reset all filters
234 | const resetFilters = (): void => {
235 | // Reset category filters
236 | const resetCategoryState: SelectedFiltersType = {};
237 |
238 | Object.keys(categoryFilters).forEach(category => {
239 | resetCategoryState[category] = {};
240 | categoryFilters[category].forEach(value => {
241 | resetCategoryState[category][value] = true;
242 | });
243 | });
244 |
245 | setSelectedCategoryFilters(resetCategoryState);
246 |
247 | // Reset numeric filters
248 | const resetNumericState: SelectedNumericFiltersType = {};
249 |
250 | Object.keys(numericFilters).forEach(field => {
251 | resetNumericState[field] = {
252 | min: numericFilters[field].min !== null ? String(numericFilters[field].min) : '',
253 | max: numericFilters[field].max !== null ? String(numericFilters[field].max) : ''
254 | };
255 | });
256 |
257 | setSelectedNumericFilters(resetNumericState);
258 |
259 | // Reset H3 resolution
260 | setH3Resolution(String(initialH3Resolution));
261 | setH3Error('');
262 |
263 | // Reset opacity
264 | setOpacity(initialOpacity);
265 |
266 | // Reset height multiplier
267 | setHeightMultiplier(String(initialHeightMultiplier));
268 | setHeightMultiplierError('');
269 |
270 | // Apply the reset
271 | onFilterChange(
272 | categoryFilters,
273 | numericFilters,
274 | initialH3Resolution,
275 | initialOpacity,
276 | initialHeightMultiplier
277 | );
278 | };
279 |
280 | if (!isOpen) {
281 | return (
282 |
283 |
284 |
285 |
286 | Filters
287 |
288 | );
289 | }
290 |
291 | return (
292 |
293 |
294 |
Filter Map Data
295 |
×
296 |
297 |
298 | {/* Category Filters */}
299 | {Object.keys(categoryFilters).length > 0 && (
300 |
301 |
Categories
302 |
303 | {Object.keys(categoryFilters).map(category => (
304 |
305 |
{category}
306 |
307 | {categoryFilters[category].map(value => (
308 |
309 | handleCheckboxChange(category, value)}
315 | />
316 |
320 | {value}
321 |
322 |
323 | ))}
324 |
325 |
326 | ))}
327 |
328 | )}
329 |
330 | {/* Numeric Filters */}
331 | {Object.keys(numericFilters).length > 0 && (
332 |
333 |
Numeric Ranges
334 |
335 | {Object.keys(numericFilters).map(field => (
336 |
337 |
{field}
338 |
360 |
361 | ))}
362 |
363 | )}
364 |
365 | {/* H3 Resolution Input */}
366 |
367 |
Hexagon Resolution
368 |
369 |
370 |
371 | handleH3ResolutionChange(e.target.value)}
376 | placeholder="Enter value (1-15)"
377 | />
378 |
379 |
380 | {h3Error &&
{h3Error}
}
381 |
382 |
383 |
384 | {/* Height Multiplier Input */}
385 |
386 |
Height Multiplier
387 |
388 |
389 |
390 | handleHeightMultiplierChange(e.target.value)}
395 | placeholder="Enter value (≥ 0)"
396 | />
397 |
398 |
399 | {heightMultiplierError &&
{heightMultiplierError}
}
400 |
401 |
402 |
403 | {/* Opacity Slider */}
404 |
405 |
Opacity
406 |
407 |
408 |
417 |
{opacity.toFixed(2)}
418 |
419 |
420 |
421 |
422 |
423 |
424 | Reset
425 |
426 |
431 | Apply Filters
432 |
433 | {/* {
435 | onToggle();
436 | (window as any).myMap.easeTo({
437 | center: [8.543937,47.371305], // Longitude, Latitude (Zurich, Switzerland)
438 | zoom: 15, // Target zoom level
439 | bearing:60, // Rotation angle in degrees
440 | pitch: 60, // Tilt angle in degrees
441 | duration: 3000, // Duration in milliseconds (2 seconds)
442 | });
443 | }}
444 | >
445 | bicycle
446 |
447 | {
449 | onToggle();
450 | (window as any).myMap.easeTo({
451 | center: [8.56048, 47.365], // Longitude, Latitude (Zurich, Switzerland)
452 | zoom: 13, // Target zoom level
453 | bearing:120.1, // Rotation angle in degrees
454 | pitch: 60, // Tilt angle in degrees
455 | duration: 3000, // Duration in milliseconds (2 seconds)
456 | });
457 | }}
458 | >
459 | overtaking
460 | */}
461 |
462 |
463 | );
464 | };
465 |
466 | export default FilterOverlay;
--------------------------------------------------------------------------------
/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/declarations.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*.json" {
2 | const value: any;
3 | export default value;
4 | }
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | /* App.css */
2 |
3 |
4 | /* General Styles */
5 | * {
6 | box-sizing: border-box;
7 | margin: 0;
8 | padding: 0;
9 | }
10 |
11 | body, html {
12 | width: 100%;
13 | height: 100%;
14 | max-height: 100%;
15 | overflow: hidden;
16 | font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
17 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
18 | -webkit-font-smoothing: antialiased;
19 | -moz-osx-font-smoothing: grayscale;
20 | color: #111827;
21 | line-height: 1.5;
22 | }
23 |
24 | .container {
25 | position: relative;
26 | width: 100vw;
27 | height: 100vh;
28 | padding: 0;
29 | margin: 0;
30 | }
31 |
32 | /* Typography */
33 | .title {
34 | position: absolute;
35 | top: 1rem;
36 | left: 1rem;
37 | font-size: 1.5rem;
38 | font-weight: 700;
39 | color: #1f2937;
40 | z-index: 10;
41 | background-color: rgba(255, 255, 255, 0.8);
42 | padding: 0.5rem 1rem;
43 | border-radius: 0.375rem;
44 | }
45 |
46 | /* Error Message */
47 | .error-message {
48 | position: absolute;
49 | top: 5rem;
50 | left: 50%;
51 | transform: translateX(-50%);
52 | background-color: #fee2e2;
53 | border: 1px solid #ef4444;
54 | color: #b91c1c;
55 | padding: 0.75rem 1rem;
56 | border-radius: 0.375rem;
57 | z-index: 20;
58 | max-width: 90%;
59 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
60 | }
61 |
62 | /* Drop Zone */
63 | .drop-zone {
64 | position: absolute;
65 | top: 50%;
66 | left: 50%;
67 | transform: translate(-50%, -50%);
68 | border: 2px dashed #d1d5db;
69 | border-radius: 0.5rem;
70 | padding: 2.5rem;
71 | text-align: center;
72 | cursor: pointer;
73 | z-index: 10;
74 | background-color: rgba(255, 255, 255, 0.9);
75 | box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
76 | max-width: 90%;
77 | width: 450px;
78 | transition: background-color 0.3s, border-color 0.3s;
79 | }
80 |
81 | .drop-zone:hover {
82 | border-color: #93c5fd;
83 | background-color: rgba(255, 255, 255, 0.95);
84 | }
85 |
86 | .drop-zone.dragging {
87 | border-color: #3b82f6;
88 | background-color: rgba(239, 246, 255, 0.95);
89 | }
90 |
91 | .drop-text {
92 | margin-bottom: 0.5rem;
93 | color: #374151;
94 | font-weight: 500;
95 | }
96 |
97 | .note-text {
98 | font-size: 0.875rem;
99 | color: #6b7280;
100 | }
101 |
102 | .hidden-input {
103 | display: none;
104 | }
105 |
106 | /* Loading Spinner */
107 | .loading-container {
108 | position: absolute;
109 | top: 50%;
110 | left: 50%;
111 | transform: translate(-50%, -50%);
112 | z-index: 20;
113 | display: flex;
114 | justify-content: center;
115 | align-items: center;
116 | background-color: rgba(255, 255, 255, 0.7);
117 | padding: 2rem;
118 | border-radius: 0.5rem;
119 | box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
120 | }
121 |
122 | .loading-spinner {
123 | border: 0.25rem solid #e5e7eb;
124 | border-radius: 50%;
125 | border-top: 0.25rem solid #3b82f6;
126 | width: 2.5rem;
127 | height: 2.5rem;
128 | animation: spin 1s linear infinite;
129 | }
130 |
131 | @keyframes spin {
132 | 0% { transform: rotate(0deg); }
133 | 100% { transform: rotate(360deg); }
134 | }
135 |
136 | /* Buttons */
137 | .button-group {
138 | position: absolute;
139 | bottom: 1.5rem;
140 | right: 1.5rem;
141 | z-index: 10;
142 | display: flex;
143 | gap: 0.5rem;
144 | }
145 |
146 | .primary-button {
147 | background-color: #3b82f6;
148 | color: white;
149 | padding: 0.5rem 1rem;
150 | border-radius: 0.25rem;
151 | font-weight: 500;
152 | border: none;
153 | cursor: pointer;
154 | transition: background-color 0.2s;
155 | }
156 |
157 | .primary-button:hover {
158 | background-color: #2563eb;
159 | }
160 |
161 | .secondary-button {
162 | background-color: #6b7280;
163 | color: white;
164 | padding: 0.5rem 1rem;
165 | border-radius: 0.25rem;
166 | font-weight: 500;
167 | border: none;
168 | cursor: pointer;
169 | transition: background-color 0.2s;
170 | }
171 |
172 | .secondary-button:hover {
173 | background-color: #4b5563;
174 | }
175 |
176 | /* Map Styles */
177 | .map-container {
178 | position: absolute;
179 | top: 0;
180 | left: 0;
181 | width: 100vw;
182 | height: 100dvh;
183 | max-height: 100%;
184 | z-index: 1;
185 | }
186 |
187 | /* MapLibre Popup Styles */
188 | .popup-content {
189 | padding: 0.5rem;
190 | font-size: 0.875rem;
191 | }
192 |
193 | .popup-content p {
194 | margin-bottom: 0.25rem;
195 | }
196 |
197 | .popup-content strong {
198 | font-weight: 600;
199 | }
200 |
201 | /* Filter Overlay Styles */
202 | .filter-overlay {
203 | position: absolute;
204 | top: 4rem;
205 | right: 1rem;
206 | background-color: white;
207 | border-radius: 0.5rem;
208 | box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
209 | padding: 1rem;
210 | z-index: 10;
211 | max-width: 300px;
212 | max-height: calc(100vh - 8rem);
213 | overflow-y: auto;
214 | border: 1px solid #e5e7eb;
215 | }
216 |
217 | .filter-header {
218 | display: flex;
219 | justify-content: space-between;
220 | align-items: center;
221 | margin-bottom: 0.75rem;
222 | padding-bottom: 0.5rem;
223 | border-bottom: 1px solid #e5e7eb;
224 | }
225 |
226 | .filter-title {
227 | font-size: 1rem;
228 | font-weight: 600;
229 | color: #1f2937;
230 | }
231 |
232 | .toggle-filter-btn {
233 | position: absolute;
234 | top: 1rem;
235 | right: 1rem;
236 | z-index: 10;
237 | background-color: white;
238 | border: 1px solid #e5e7eb;
239 | border-radius: 0.25rem;
240 | padding: 0.5rem;
241 | display: flex;
242 | align-items: center;
243 | gap: 0.25rem;
244 | cursor: pointer;
245 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
246 | }
247 |
248 | .toggle-filter-btn:hover {
249 | background-color: #f9fafb;
250 | }
251 |
252 | /* Filter Groups */
253 | .filter-group {
254 | margin-bottom: 1.25rem;
255 | padding-bottom: 1rem;
256 | border-bottom: 1px solid #e5e7eb;
257 | }
258 |
259 | .filter-group:last-child {
260 | border-bottom: none;
261 | padding-bottom: 0;
262 | }
263 |
264 | .filter-group-title {
265 | font-size: 0.875rem;
266 | font-weight: 600;
267 | color: #6b7280;
268 | text-transform: uppercase;
269 | letter-spacing: 0.05em;
270 | margin-bottom: 0.75rem;
271 | }
272 |
273 | .filter-section {
274 | margin-bottom: 1rem;
275 | }
276 |
277 | .filter-category {
278 | font-weight: 600;
279 | color: #374151;
280 | margin-bottom: 0.5rem;
281 | }
282 |
283 | /* Category Filters */
284 | .filter-options {
285 | display: flex;
286 | flex-direction: column;
287 | gap: 0.375rem;
288 | margin-bottom: 0.75rem;
289 | }
290 |
291 | .filter-option {
292 | display: flex;
293 | align-items: center;
294 | gap: 0.5rem;
295 | }
296 |
297 | .filter-checkbox {
298 | width: 1rem;
299 | height: 1rem;
300 | accent-color: #3b82f6;
301 | cursor: pointer;
302 | }
303 |
304 | .filter-label {
305 | font-size: 0.875rem;
306 | color: #4b5563;
307 | cursor: pointer;
308 | }
309 |
310 | /* Numeric Filters */
311 | .numeric-filter-inputs {
312 | display: flex;
313 | gap: 0.5rem;
314 | margin-bottom: 0.5rem;
315 | }
316 |
317 | .numeric-input-group {
318 | display: flex;
319 | flex-direction: column;
320 | flex: 1;
321 | }
322 |
323 | .numeric-label {
324 | font-size: 0.75rem;
325 | color: #6b7280;
326 | margin-bottom: 0.25rem;
327 | }
328 |
329 | .numeric-input {
330 | padding: 0.375rem 0.5rem;
331 | border: 1px solid #d1d5db;
332 | border-radius: 0.25rem;
333 | font-size: 0.875rem;
334 | width: 100%;
335 | }
336 |
337 | .numeric-input:focus {
338 | outline: none;
339 | border-color: #93c5fd;
340 | box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
341 | }
342 |
343 |
344 | /* Filter Actions */
345 | .filter-actions {
346 | display: flex;
347 | justify-content: space-between;
348 | margin-top: 1rem;
349 | padding-top: 0.75rem;
350 | border-top: 1px solid #e5e7eb;
351 | }
352 |
353 | .apply-filters-btn {
354 | background-color: #3b82f6;
355 | color: white;
356 | padding: 0.375rem 0.75rem;
357 | border-radius: 0.25rem;
358 | font-size: 0.875rem;
359 | font-weight: 500;
360 | border: none;
361 | cursor: pointer;
362 | transition: background-color 0.2s;
363 | }
364 |
365 | .apply-filters-btn:hover {
366 | background-color: #2563eb;
367 | }
368 |
369 | .reset-filters-btn {
370 | background-color: #f3f4f6;
371 | color: #4b5563;
372 | padding: 0.375rem 0.75rem;
373 | border-radius: 0.25rem;
374 | font-size: 0.875rem;
375 | font-weight: 500;
376 | border: 1px solid #d1d5db;
377 | cursor: pointer;
378 | transition: background-color 0.2s;
379 | }
380 |
381 | .reset-filters-btn:hover {
382 | background-color: #e5e7eb;
383 | }
384 |
385 | .close-filter-btn {
386 | background: transparent;
387 | border: none;
388 | cursor: pointer;
389 | color: #6b7280;
390 | padding: 0.25rem;
391 | font-size: 1.25rem;
392 | line-height: 1;
393 | }
394 |
395 | .close-filter-btn:hover {
396 | color: #4b5563;
397 | }
398 |
399 | /* Hide filters button when panel is open */
400 | .filters-open .toggle-filter-btn {
401 | display: none;
402 | }
403 |
404 | .hover-metadata-overlay {
405 | position: absolute;
406 | top: 100px;
407 | left: 20px;
408 | background-color: rgba(255, 255, 255, 0.9);
409 | border-radius: 4px;
410 | padding: 12px;
411 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
412 | z-index: 1000;
413 | min-width: 220px;
414 | transition: opacity 0.2s ease;
415 | }
416 |
417 | .hover-metadata-overlay.hidden {
418 | opacity: 0;
419 | pointer-events: none;
420 | }
421 |
422 | .hover-metadata-overlay.visible {
423 | opacity: 1;
424 | }
425 |
426 | .close-button {
427 | background: none;
428 | border: none;
429 | font-size: 16px;
430 | cursor: pointer;
431 | float: right;
432 | color: #555;
433 | }
434 |
435 | .close-button:hover {
436 | color: #000;
437 | }
438 |
439 | .metadata-header {
440 | font-weight: bold;
441 | margin-bottom: 8px;
442 | border-bottom: 1px solid #eee;
443 | padding-bottom: 5px;
444 | }
445 |
446 | .metadata-item {
447 | margin: 5px 0;
448 | display: flex;
449 | justify-content: space-between;
450 | }
451 |
452 | .metadata-label {
453 | font-weight: 500;
454 | }
455 |
456 | .metadata-value {
457 | margin-left: 10px;
458 | }
459 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import App from './App.tsx'
4 | import './index.css';
5 |
6 | createRoot(document.getElementById('root')!).render(
7 |
8 |
9 | ,
10 | )
11 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true
24 | },
25 | "include": ["src"]
26 | }
27 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vite.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | base: './'
8 | })
9 |
--------------------------------------------------------------------------------