├── .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 |
716 |
717 |
718 | )} 719 | 720 | 730 | 731 |
732 | {hoverData && ( 733 | <> 734 |
735 | Hover Details 736 | 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 |
757 |
761 |
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 | 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 | 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 |
339 |
340 | 341 | handleNumericInputChange(field, 'min', e.target.value)} 346 | placeholder="Minimum" 347 | /> 348 |
349 |
350 | 351 | handleNumericInputChange(field, 'max', e.target.value)} 356 | placeholder="Maximum" 357 | /> 358 |
359 |
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 | 426 | 433 | {/* 447 | */} 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 | --------------------------------------------------------------------------------