├── static └── .gitkeep ├── .eslintignore ├── babel.config.js ├── images └── tokyo_and_seattle.png ├── src ├── components │ ├── vue3-color │ │ ├── LICENSE │ │ ├── common │ │ │ ├── Checkboard.vue │ │ │ ├── EditableInput.vue │ │ │ ├── Alpha.vue │ │ │ ├── Saturation.vue │ │ │ └── Hue.vue │ │ ├── mixin │ │ │ └── color.js │ │ └── Sketch.vue │ ├── LoadingIcon.vue │ ├── EditableLabel.vue │ ├── clickOutside.js │ ├── ColorPicker.vue │ └── FindPlace.vue ├── lib │ ├── bus.js │ ├── Progress.js │ ├── protobufExport.js │ ├── appState.js │ ├── postData.js │ ├── getZazzleLink.js │ ├── findBoundaryByName.js │ ├── BoundingBox.js │ ├── svgExport.js │ ├── request.js │ ├── LoadOptions.js │ ├── Query.js │ ├── Grid.js │ ├── canvas2BlobPolyfill.js │ ├── GridLayer.js │ ├── saveFile.js │ └── createScene.js ├── proto │ ├── decode.js │ ├── place.proto │ ├── encode.js │ └── place.js ├── vars.styl ├── config.js ├── main.js ├── NoWebGL.vue ├── createOverlayManager.js └── App.vue ├── .github └── FUNDING.yml ├── .editorconfig ├── .gitignore ├── deploy.sh ├── .eslintrc.cjs ├── vite.config.js ├── package.json ├── LICENSE ├── index.html ├── README.md └── API.md /static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /config/ 3 | /dist/ 4 | /*.js 5 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /images/tokyo_and_seattle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anvaka/city-roads/HEAD/images/tokyo_and_seattle.png -------------------------------------------------------------------------------- /src/components/vue3-color/LICENSE: -------------------------------------------------------------------------------- 1 | Based on @lk77/vue3-color which is licensed under The MIT License. 2 | 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: anvaka 4 | patreon: anvaka 5 | custom: ['https://www.paypal.me/anvakos/3'] 6 | -------------------------------------------------------------------------------- /src/lib/bus.js: -------------------------------------------------------------------------------- 1 | import eventify from 'ngraph.events'; 2 | 3 | // we are going to use this as a global message bus inside the app. 4 | export default eventify({}); -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | /dist/ 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Editor directories and files 9 | .idea 10 | .vscode 11 | *.suo 12 | *.ntvs* 13 | *.njsproj 14 | *.sln 15 | stats.html -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | rm -rf ./dist 3 | npm run build 4 | cd ./dist 5 | git init 6 | git add . 7 | git commit -m 'push to gh-pages' 8 | git push --force git@github.com:anvaka/city-roads.git main:gh-pages 9 | cd ../ 10 | git tag `date "+release-%Y%m%d%H%M%S"` 11 | git push --tags 12 | -------------------------------------------------------------------------------- /src/proto/decode.js: -------------------------------------------------------------------------------- 1 | let fs = require('fs'); 2 | let data = require('./test-data.json'); 3 | var Pbf = require('pbf'); 4 | var place = require('./place.js').place; 5 | let buffer = fs.readFileSync(process.argv[2] || 'out1.pbf'); 6 | var pbf = new Pbf(buffer); 7 | var obj = place.read(pbf); 8 | console.log(obj); 9 | -------------------------------------------------------------------------------- /src/vars.styl: -------------------------------------------------------------------------------- 1 | small-screen = 450px; 2 | desktop-controls-width = 442px; 3 | labels-font = 'Roboto', sans-serif; 4 | 5 | highlight-color = #ff4081; 6 | primary-text = rgb(33, 33, 33); 7 | secondary-color = rgba(0,0,0,.54); 8 | emphasis-background = white; 9 | background-color = #F7F2E8; 10 | border-color = #E9EAED; -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | root: true, 4 | extends: [ 5 | 'plugin:vue/vue3-essential', 6 | 'eslint:recommended' 7 | ], 8 | rules: { 9 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 10 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 11 | 'no-unused-vars': 1 12 | }, 13 | env: { 14 | 'vue/setup-compiler-macros': true 15 | } 16 | } -------------------------------------------------------------------------------- /src/components/LoadingIcon.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/proto/place.proto: -------------------------------------------------------------------------------- 1 | message place { 2 | message node { 3 | optional uint64 id = 1; 4 | optional float lat = 2; 5 | optional float lon = 3; 6 | } 7 | 8 | message way { 9 | repeated uint64 nodes = 1 [packed = true]; 10 | } 11 | 12 | required uint32 version = 1 [ default = 1 ]; 13 | 14 | required string name = 2; 15 | required string date = 3; 16 | required string id = 4; 17 | repeated node nodes = 5; 18 | repeated way ways = 6; 19 | 20 | extensions 16 to 8191; 21 | } -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'url' 2 | 3 | import { defineConfig } from 'vite' 4 | import vue from '@vitejs/plugin-vue' 5 | import { visualizer } from "rollup-plugin-visualizer"; 6 | 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | plugins: [vue(), visualizer({ 11 | // template: 'network' 12 | })], 13 | base: '', 14 | server: { 15 | port: 8080 16 | }, 17 | resolve: { 18 | alias: { 19 | '@': fileURLToPath(new URL('./src', import.meta.url)) 20 | } 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /src/lib/Progress.js: -------------------------------------------------------------------------------- 1 | import eventify from 'ngraph.events'; 2 | 3 | export default class Progress { 4 | constructor(notify) { 5 | eventify(this) 6 | this.callback = notify || Function.prototype; 7 | } 8 | 9 | cancel() { 10 | this.isCancelled = true; 11 | this.fire('cancel'); 12 | } 13 | 14 | notify(progress) { 15 | if (!this.isCancelled) { 16 | this.callback(progress); 17 | } 18 | } 19 | 20 | onCancel(callback) { 21 | this.on('cancel', callback, this); 22 | } 23 | 24 | offCancel(callback) { 25 | this.off('cancel', callback); 26 | } 27 | } -------------------------------------------------------------------------------- /src/proto/encode.js: -------------------------------------------------------------------------------- 1 | let fs = require('fs'); 2 | let data = require('./test-data.json'); 3 | var Pbf = require('pbf'); 4 | var place = require('./place.js').place; 5 | var pbf = new Pbf() 6 | let nodes = []; 7 | let ways = []; 8 | 9 | data.forEach(x => { 10 | let elementType = 0; 11 | if (x.type === 'node') { 12 | nodes.push(x) 13 | } else if (x.type === 'way') { 14 | ways.push(x) 15 | } 16 | }); 17 | 18 | place.write({ 19 | name: 'test', 20 | nodes, ways 21 | }, pbf) 22 | var buffer = pbf.finish(); 23 | console.log(buffer.length); 24 | fs.writeFileSync('out1.pbf', buffer); 25 | -------------------------------------------------------------------------------- /src/lib/protobufExport.js: -------------------------------------------------------------------------------- 1 | import Pbf from 'pbf'; 2 | import {place} from '../proto/place.js'; 3 | 4 | export default function protoBufExport(grid) { 5 | let nodes = []; 6 | let ways = []; 7 | let date = (new Date()).toISOString(); 8 | 9 | grid.forEachElement(x => { 10 | let elementType = 0; 11 | if (x.type === 'node') { 12 | nodes.push(x) 13 | } else if (x.type === 'way') { 14 | ways.push(x) 15 | } 16 | }); 17 | 18 | let pbf = new Pbf() 19 | place.write({ 20 | version: 1, 21 | id: grid.id, 22 | date, 23 | name: grid.name, 24 | nodes, ways 25 | }, pbf); 26 | return pbf.finish(); 27 | } -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | import tinycolor from 'tinycolor2'; 2 | 3 | export default { 4 | /** 5 | * This is our caching backend 6 | */ 7 | // This used to work, but seems like GitHub no longer allows large website hosting: 8 | //areaServer: 'https://anvaka.github.io/index-large-cities/data', 9 | //areaServer: 'http://localhost:8085', // This is un-commented when I develop cache locally 10 | // So, using S3 11 | areaServer: 'https://city-roads.s3-us-west-2.amazonaws.com/nov-02-2020', 12 | 13 | getDefaultLineColor() { 14 | return tinycolor('rgba(26, 26, 26, 0.8)'); 15 | }, 16 | getLabelColor() { 17 | return tinycolor('#161616'); 18 | }, 19 | 20 | getBackgroundColor() { 21 | return tinycolor('#F7F2E8'); 22 | } 23 | } -------------------------------------------------------------------------------- /src/lib/appState.js: -------------------------------------------------------------------------------- 1 | import createQueryState from 'query-state'; 2 | 3 | const queryState = createQueryState({}, {useSearch: true}); 4 | 5 | /** 6 | * This is our base state. It just persists default information about 7 | * custom settings and integrates with query string. 8 | */ 9 | export default { 10 | isCacheEnabled() { 11 | return queryState.get('cache') != 0; 12 | }, 13 | enableCache() { 14 | return queryState.unset('cache'); 15 | }, 16 | get() { 17 | return queryState.get.apply(queryState, arguments); 18 | }, 19 | set() { 20 | return queryState.set.apply(queryState, arguments); 21 | }, 22 | unset() { 23 | return queryState.unset.apply(queryState, arguments); 24 | }, 25 | 26 | unsetPlace() { 27 | queryState.unset('areaId'); 28 | queryState.unset('osm_id'); 29 | queryState.unset('bbox'); 30 | } 31 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "city-roads", 3 | "version": "1.0.0", 4 | "description": "Visualization of all roads in a city", 5 | "author": "Andrei Kashcha", 6 | "private": true, 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "vite build", 10 | "start": "vite", 11 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore" 12 | }, 13 | "dependencies": { 14 | "d3-geo": "^3.0.1", 15 | "d3-require": "^1.3.0", 16 | "ngraph.events": "^1.2.1", 17 | "pbf": "^3.2.1", 18 | "query-state": "^4.3.0", 19 | "tinycolor2": "^1.4.2", 20 | "vue": "^3.2.37", 21 | "w-gl": "^0.21.0" 22 | }, 23 | "devDependencies": { 24 | "@vitejs/plugin-vue": "^2.3.1", 25 | "eslint": "^8.5.0", 26 | "eslint-plugin-vue": "^8.2.0", 27 | "rollup-plugin-visualizer": "^5.6.0", 28 | "stylus": "^0.58.1", 29 | "stylus-loader": "^7.0.0", 30 | "vite": "^2.9.5" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // The Vue build version to load with the `import` command 2 | // (runtime-only or standalone) has been set in webpack.base.conf with an alias. 3 | import {createApp} from 'vue'; 4 | import {require as d3Require} from 'd3-require'; 5 | import {isWebGLEnabled} from 'w-gl'; 6 | import App from './App.vue'; 7 | import NoWebGL from './NoWebGL.vue'; 8 | import Query from './lib/Query.js'; 9 | 10 | // const wgl = require('w-gl'); 11 | 12 | window.addEventListener('error', logError); 13 | 14 | // expose the console API 15 | window.requireModule = d3Require; 16 | window.Query = Query; 17 | 18 | if (isWebGLEnabled(document.querySelector('#canvas'))) { 19 | createApp(App).mount('#host'); 20 | } else { 21 | createApp(NoWebGL).mount('#host'); 22 | } 23 | 24 | function logError(e) { 25 | if (typeof gtag !== 'function') return; 26 | 27 | const exDescription = e ? `${e.message} in ${e.filename}:${e.lineno}` : 'Unknown exception'; 28 | 29 | gtag('send', 'exception', { 30 | description: exDescription, 31 | fatal: false 32 | }); 33 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2025 Andrei Kashcha 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/NoWebGL.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /src/lib/postData.js: -------------------------------------------------------------------------------- 1 | import request from './request.js'; 2 | import Progress from './Progress.js'; 3 | 4 | let backends = [ 5 | 'https://overpass.kumi.systems/api/interpreter', 6 | 'https://overpass-api.de/api/interpreter', 7 | 'https://overpass.openstreetmap.ru/cgi/interpreter' 8 | ] 9 | 10 | export default function postData(data, progress) { 11 | progress = progress || new Progress(); 12 | const postData = { 13 | method: 'POST', 14 | responseType: 'json', 15 | progress, 16 | headers: { 17 | 'Content-type': 'application/x-www-form-urlencoded; charset=UTF-8' 18 | }, 19 | body: 'data=' + encodeURIComponent(data), 20 | }; 21 | 22 | let serverIndex = 0; 23 | 24 | return fetchFrom(backends[serverIndex]); 25 | 26 | function fetchFrom(overpassUrl) { 27 | return request(overpassUrl, postData, 'POST') 28 | .catch(handleError); 29 | } 30 | 31 | function handleError(err) { 32 | if (err.cancelled) throw err; 33 | 34 | if (serverIndex >= backends.length - 1) { 35 | // we can't do much anymore 36 | throw err; 37 | } 38 | 39 | if (err.statusError) { 40 | progress.notify({ 41 | loaded: -1 42 | }); 43 | } 44 | 45 | serverIndex += 1; 46 | return fetchFrom(backends[serverIndex]) 47 | } 48 | } -------------------------------------------------------------------------------- /src/lib/getZazzleLink.js: -------------------------------------------------------------------------------- 1 | import request from './request.js'; 2 | import Progress from './Progress.js'; 3 | 4 | let imageUrl = 'https://edi6jgnosf.execute-api.us-west-2.amazonaws.com/Stage/put_image' 5 | 6 | const productKinds = { 7 | mug: '168739066664861503' 8 | }; 9 | 10 | function getZazzleLink(kind, imageUrl) { 11 | const productCode = productKinds[kind]; 12 | if (!productCode) { 13 | throw new Error('Unknown product kind: ' + kind); 14 | } 15 | 16 | const imageEncoded = encodeURIComponent(imageUrl); 17 | return `https://www.zazzle.com/api/create/at-238058511445368984?rf=238058511445368984&ax=Linkover&pd=${productCode}&ed=true&tc=&ic=&t_map_iid=${imageEncoded}`; 18 | } 19 | 20 | export default function generateZazzleLink(canvas) { 21 | var imageContent = canvas.toDataURL('image/png').replace(/^data:image\/(png|jpg);base64,/, ''); 22 | const form = new FormData(); 23 | form.append('image', imageContent); 24 | 25 | return request(imageUrl, { 26 | method: 'POST', 27 | responseType: 'json', 28 | progress: new Progress(Function.prototype), 29 | body: form, 30 | }, 'POST').then(x => { 31 | if (!x.success) throw new Error('Failed to upload image'); 32 | let link = x.data.link; 33 | return getZazzleLink('mug', link); 34 | }).catch(e => { 35 | console.log('error', e); 36 | throw e; 37 | }); 38 | } -------------------------------------------------------------------------------- /src/components/EditableLabel.vue: -------------------------------------------------------------------------------- 1 | 13 | 35 | -------------------------------------------------------------------------------- /src/components/clickOutside.js: -------------------------------------------------------------------------------- 1 | // Based on https://github.com/ElemeFE/element/blob/dev/src/utils/clickoutside.js 2 | // The MIT License (MIT), Copyright (c) 2016 ElemeFE 3 | // (C) 2022 anvaka 4 | const nodeList = []; 5 | const ctx = '@@clickoutsideContext'; 6 | 7 | let startClick; 8 | let seed = 0; 9 | 10 | document.addEventListener('mousedown', e => (startClick = e), true); 11 | document.addEventListener('mouseup', e => { 12 | nodeList.forEach(node => node[ctx].documentHandler(e, startClick)); 13 | }, true); 14 | 15 | // Also hide when tapped outside. 16 | document.addEventListener('touchstart', e => { 17 | startClick = e; 18 | }, true); 19 | document.addEventListener('touchend', e => { 20 | nodeList.forEach(node => node[ctx].documentHandler(e, startClick)); 21 | }, true); 22 | 23 | function createDocumentHandler(el, binding, vnode) { 24 | return function(mouseup = {}, mousedown = {}) { 25 | if (!vnode || !mouseup.target || !mousedown.target || 26 | el.contains(mouseup.target) || el.contains(mousedown.target) || el === mouseup.target) return; 27 | 28 | const methodName = el[ctx].handler; 29 | if (methodName) methodName() 30 | }; 31 | } 32 | 33 | export default { 34 | created(el, binding, vnode) { 35 | nodeList.push(el); 36 | const id = seed++; 37 | el[ctx] = { 38 | id, 39 | documentHandler: createDocumentHandler(el, binding, vnode), 40 | handler: binding.value 41 | }; 42 | }, 43 | 44 | updated(el, binding, vnode) { 45 | el[ctx].documentHandler = createDocumentHandler(el, binding, vnode); 46 | el[ctx].handler = binding.value; 47 | }, 48 | 49 | unmounted(el) { 50 | let len = nodeList.length; 51 | 52 | for (let i = 0; i < len; i++) { 53 | if (nodeList[i][ctx].id === el[ctx].id) { 54 | nodeList.splice(i, 1); 55 | break; 56 | } 57 | } 58 | delete el[ctx]; 59 | } 60 | }; -------------------------------------------------------------------------------- /src/lib/findBoundaryByName.js: -------------------------------------------------------------------------------- 1 | import request from './request.js'; 2 | 3 | let cachedResults = new Map(); 4 | 5 | export default function findBoundaryByName(inputName) { 6 | let results = cachedResults.get(inputName); 7 | if (results) return Promise.resolve(results); 8 | 9 | let name = encodeURIComponent(inputName); 10 | return request(`https://nominatim.openstreetmap.org/search?format=json&q=${name}`, {responseType: 'json'}) 11 | .then(extractBoundaries) 12 | .then(x => { 13 | cachedResults.set(inputName, x); 14 | return x; 15 | }); 16 | } 17 | 18 | function extractBoundaries(x) { 19 | let areas = x.map(row => { 20 | let areaId, bbox; 21 | if (row.osm_type === 'relation') { 22 | // By convention the area id can be calculated from an existing 23 | // OSM way by adding 2400000000 to its OSM id, or in case of a 24 | // relation by adding 3600000000 respectively. So we are adding this 25 | // https://wiki.openstreetmap.org/wiki/Overpass_API/Overpass_QL#By_area_.28area.29 26 | // Note: we may want to do another case for osm_type = 'way'. Need to check 27 | // if it returns correct values. 28 | areaId = row.osm_id + 36e8; 29 | } else if (row.osm_type === 'way') { 30 | areaId = row.osm_id + 24e8; 31 | } 32 | if (row.boundingbox) { 33 | bbox = [ 34 | Number.parseFloat(row.boundingbox[0]), 35 | Number.parseFloat(row.boundingbox[2]), 36 | Number.parseFloat(row.boundingbox[1]), 37 | Number.parseFloat(row.boundingbox[3]), 38 | ]; 39 | } 40 | 41 | return { 42 | areaId, 43 | bbox, 44 | lat: row.lat, 45 | lon: row.lon, 46 | osmId: row.osm_id, 47 | osmType: row.osm_type, 48 | name: row.display_name, 49 | type: row.type, 50 | }; 51 | }); 52 | 53 | return areas; 54 | } -------------------------------------------------------------------------------- /src/lib/BoundingBox.js: -------------------------------------------------------------------------------- 1 | export default class BBox { 2 | constructor() { 3 | this.minX = Infinity; 4 | this.minY = Infinity; 5 | this.maxX = -Infinity; 6 | this.maxY = -Infinity; 7 | } 8 | 9 | growBy(offset) { 10 | this.minX -= offset; 11 | this.minY -= offset; 12 | this.maxX += offset; 13 | this.maxY += offset; 14 | } 15 | 16 | get left() { 17 | return this.minX; 18 | } 19 | 20 | get top() { 21 | return this.minY; 22 | } 23 | 24 | get right() { 25 | return this.maxX; 26 | } 27 | 28 | get bottom() { 29 | return this.maxY; 30 | } 31 | 32 | get width() { 33 | return this.maxX - this.minX; 34 | } 35 | 36 | get height() { 37 | return this.maxY - this.minY; 38 | } 39 | 40 | get cx() { 41 | return (this.minX + this.maxX)/2; 42 | } 43 | 44 | get cy() { 45 | return (this.minY + this.maxY)/2; 46 | } 47 | 48 | addPoint(xIn, yIn) { 49 | if (xIn === undefined) throw new Error('Point is not defined'); 50 | let x = xIn; 51 | let y = yIn; 52 | if (y === undefined) { 53 | // xIn is a point object 54 | x = xIn.x; 55 | y = xIn.y; 56 | } 57 | 58 | if (x < this.minX) this.minX = x; 59 | if (x > this.maxX) this.maxX = x; 60 | if (y < this.minY) this.minY = y; 61 | if (y > this.maxY) this.maxY = y; 62 | } 63 | 64 | addRect(rect) { 65 | if (!rect) throw new Error('rect is not defined'); 66 | this.addPoint(rect.left, rect.top); 67 | this.addPoint(rect.right, rect.top); 68 | this.addPoint(rect.left, rect.bottom); 69 | this.addPoint(rect.right, rect.bottom); 70 | } 71 | 72 | merge(otherBBox) { 73 | if (otherBBox.minX < this.minX) this.minX = otherBBox.minX; 74 | if (otherBBox.minY < this.minY) this.minY = otherBBox.minY; 75 | if (otherBBox.maxX > this.maxX) this.maxX = otherBBox.maxX; 76 | if (otherBBox.maxY > this.maxY) this.maxY = otherBBox.maxY; 77 | } 78 | } -------------------------------------------------------------------------------- /src/components/vue3-color/common/Checkboard.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 83 | 84 | 94 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | Draw all roads in a city at once 23 | 24 | 25 | 52 | 53 | 54 | 55 | 56 |
57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /src/proto/place.js: -------------------------------------------------------------------------------- 1 | 'use strict'; // code generated by pbf v3.2.1 2 | 3 | // place ======================================== 4 | 5 | export const place = {}; 6 | 7 | place.read = function (pbf, end) { 8 | return pbf.readFields(place._readField, {version: 0, name: "", date: "", id: "", nodes: [], ways: []}, end); 9 | }; 10 | place._readField = function (tag, obj, pbf) { 11 | if (tag === 1) obj.version = pbf.readVarint(); 12 | else if (tag === 2) obj.name = pbf.readString(); 13 | else if (tag === 3) obj.date = pbf.readString(); 14 | else if (tag === 4) obj.id = pbf.readString(); 15 | else if (tag === 5) obj.nodes.push(place.node.read(pbf, pbf.readVarint() + pbf.pos)); 16 | else if (tag === 6) obj.ways.push(place.way.read(pbf, pbf.readVarint() + pbf.pos)); 17 | }; 18 | place.write = function (obj, pbf) { 19 | if (obj.version) pbf.writeVarintField(1, obj.version); 20 | if (obj.name) pbf.writeStringField(2, obj.name); 21 | if (obj.date) pbf.writeStringField(3, obj.date); 22 | if (obj.id) pbf.writeStringField(4, obj.id); 23 | if (obj.nodes) for (var i = 0; i < obj.nodes.length; i++) pbf.writeMessage(5, place.node.write, obj.nodes[i]); 24 | if (obj.ways) for (i = 0; i < obj.ways.length; i++) pbf.writeMessage(6, place.way.write, obj.ways[i]); 25 | }; 26 | 27 | // place.node ======================================== 28 | 29 | place.node = {}; 30 | 31 | place.node.read = function (pbf, end) { 32 | return pbf.readFields(place.node._readField, {id: 0, lat: 0, lon: 0}, end); 33 | }; 34 | place.node._readField = function (tag, obj, pbf) { 35 | if (tag === 1) obj.id = pbf.readVarint(); 36 | else if (tag === 2) obj.lat = pbf.readFloat(); 37 | else if (tag === 3) obj.lon = pbf.readFloat(); 38 | }; 39 | place.node.write = function (obj, pbf) { 40 | if (obj.id) pbf.writeVarintField(1, obj.id); 41 | if (obj.lat) pbf.writeFloatField(2, obj.lat); 42 | if (obj.lon) pbf.writeFloatField(3, obj.lon); 43 | }; 44 | 45 | // place.way ======================================== 46 | 47 | place.way = {}; 48 | 49 | place.way.read = function (pbf, end) { 50 | return pbf.readFields(place.way._readField, {nodes: []}, end); 51 | }; 52 | place.way._readField = function (tag, obj, pbf) { 53 | if (tag === 1) pbf.readPackedVarint(obj.nodes); 54 | }; 55 | place.way.write = function (obj, pbf) { 56 | if (obj.nodes) pbf.writePackedVarint(1, obj.nodes); 57 | }; 58 | -------------------------------------------------------------------------------- /src/lib/svgExport.js: -------------------------------------------------------------------------------- 1 | import {toSVG} from 'w-gl'; 2 | 3 | export default function svgExport(scene, options) { 4 | const renderer = scene.getRenderer(); 5 | const svgExportSettings = { 6 | open() { 7 | return ``; 10 | }, 11 | close() { 12 | return getPrintableElements(); 13 | } 14 | }; 15 | 16 | if (options.minLength) { 17 | svgExportSettings.beforeWrite = path => { 18 | let pathLength = 0; 19 | for (let i = 1; i < path.length; ++i) { 20 | pathLength += Math.hypot(path[i].x - path[i - 1].x, path[i].y - path[i - 1].y); 21 | if (pathLength > options.minLength) return true; 22 | } 23 | return pathLength > options.minLength; 24 | } 25 | } 26 | svgExportSettings.round = options.round; 27 | 28 | const svg = toSVG(renderer, svgExportSettings); 29 | 30 | return svg; 31 | 32 | function getPrintableElements() { 33 | let dpr = renderer.getPixelRatio(); 34 | 35 | return options.printable.map(el => { 36 | if (el.element instanceof SVGSVGElement) { 37 | let bounds = el.bounds; 38 | let x = bounds.left * dpr; 39 | let y = bounds.top * dpr; 40 | let svg = el.element; 41 | svg.setAttribute('x', bounds.left * dpr); 42 | svg.setAttribute('y', bounds.top * dpr); 43 | svg.setAttribute('width', bounds.width * dpr); 44 | svg.setAttribute('height', bounds.height * dpr); 45 | let content = new XMLSerializer().serializeToString(el.element); 46 | svg.removeAttribute('x'); 47 | svg.removeAttribute('y'); 48 | svg.removeAttribute('width'); 49 | svg.removeAttribute('height'); 50 | return content; 51 | } else { 52 | let label = el; 53 | if (!label.text) return; 54 | let insecurelyEscaped = label.text 55 | .replace(/&/g, '&') 56 | .replace(//g, '>') 58 | 59 | // Note: this is not 100% accurate, might need to be fixed eventually 60 | let bounds = label.bounds; 61 | let leftOffset = (bounds.right - label.paddingRight) * dpr; 62 | let bottomOffset = (bounds.bottom - label.paddingBottom) * dpr; 63 | let fontSize = label.fontSize * dpr; 64 | 65 | let fontFamily = label.fontFamily.replace(/"/g, '\''); 66 | return `${insecurelyEscaped}` 67 | } 68 | }).filter(x => x).join('\n') 69 | } 70 | } -------------------------------------------------------------------------------- /src/lib/request.js: -------------------------------------------------------------------------------- 1 | import Progress from './Progress.js'; 2 | 3 | export default function request(url, options) { 4 | if (!options) options = {}; 5 | let req; 6 | let progress = options.progress || new Progress(); 7 | let isCancelled = false; 8 | if (progress.on) { 9 | progress.onCancel(cancelDownload); 10 | } 11 | 12 | return new Promise(download); 13 | 14 | function cancelDownload() { 15 | isCancelled = true; 16 | if (req) { 17 | req.abort(); 18 | } 19 | } 20 | 21 | function download(resolve, reject) { 22 | req = new XMLHttpRequest(); 23 | 24 | if (typeof progress.notify === 'function') { 25 | req.addEventListener('progress', updateProgress, false); 26 | } 27 | 28 | req.addEventListener('load', transferComplete, false); 29 | req.addEventListener('error', transferFailed, false); 30 | req.addEventListener('abort', transferCanceled, false); 31 | 32 | req.open(options.method || 'GET', url); 33 | if (options.responseType) { 34 | req.responseType = options.responseType; 35 | } 36 | 37 | if (options.headers) { 38 | Object.keys(options.headers).forEach(key => { 39 | req.setRequestHeader(key, options.headers[key]); 40 | }); 41 | } 42 | 43 | if (options.method === 'POST') { 44 | req.send(options.body); 45 | } else { 46 | req.send(null); 47 | } 48 | 49 | function updateProgress(e) { 50 | if (e.lengthComputable) { 51 | progress.notify({ 52 | loaded: e.loaded, 53 | total: e.total, 54 | percent: e.loaded / e.total, 55 | lengthComputable: true 56 | }); 57 | } else { 58 | progress.notify({ 59 | loaded: e.loaded, 60 | lengthComputable: false 61 | }); 62 | } 63 | } 64 | 65 | function transferComplete() { 66 | progress.offCancel(cancelDownload); 67 | 68 | if (progress.isCancelled) return; 69 | 70 | if (req.status !== 200) { 71 | reject({ 72 | statusError: req.status, 73 | message: `Unexpected status code ${req.status} when calling ${url}` 74 | }); 75 | return; 76 | } 77 | var response = req.response; 78 | 79 | if (options.responseType === 'json' && typeof response === 'string') { 80 | // IE 81 | response = JSON.parse(response); 82 | } 83 | 84 | setTimeout(() => resolve(response), 0); 85 | } 86 | 87 | function transferFailed() { 88 | reject(`Failed to download ${url}`); 89 | } 90 | 91 | function transferCanceled() { 92 | reject({ 93 | cancelled: true, 94 | message: `Cancelled download of ${url}` 95 | }); 96 | } 97 | } 98 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # city-roads 2 | 3 | Render every single road in any city at once: https://anvaka.github.io/city-roads/ 4 | 5 | ![demo](https://i.imgur.com/6bFhX3e.png) 6 | 7 | ## How it is made? 8 | 9 | The data is fetched from OpenStreetMap using [overpass API](http://overpass-turbo.eu/). While that API 10 | is free (as long as you follow ODbL licenses), it can be rate-limited and sometimes it is slow. After all 11 | we are downloading thousands of roads within an area! 12 | 13 | To improve the performance of download, I indexed ~3,000 cities with population larger than 100,000 people and 14 | stored into a [very simple](https://github.com/anvaka/index-large-cities/blob/master/proto/place.proto) protobuf format. The cities are stored into a cache in this github [repository](https://github.com/anvaka/index-large-cities). 15 | 16 | The name resolution is done by [nominatim](https://nominatim.openstreetmap.org/) - for any query that you type 17 | into the search box it returns list of area ids. I check for the area id in my list of cached cities first, 18 | and fallback to overpass if area is not present in cache. 19 | 20 | ## Scripting 21 | 22 | Behind simple UI software engineers would also find scripting capabilities. You can develop programs on top 23 | of the city-roads. A few examples are available in [city-script](https://github.com/anvaka/city-script). Scene 24 | API is documented here: https://github.com/anvaka/city-roads/blob/main/API.md 25 | 26 | Please share your creations and do not hesitate to reach out if you have any questions. 27 | 28 | ## Limitations 29 | 30 | The rendering of the city is limited by the browser and video card memory capacity. I was able to render Seattle 31 | roads without a hiccup on a very old samsung phone, though when I tried Tokyo (with 1.4m segments) the phone 32 | was very slow. 33 | 34 | Selecting area that has millions of roads (e.g. a Washington state) may cause the page to crash even on a 35 | powerful device. 36 | 37 | Luckily, most of the cities can be rendered without problems, resulting in a beautiful art. 38 | 39 | ## Support 40 | 41 | If you like this work and want to use it in your projects - you are more than welcome to do so! 42 | 43 | Please [let me](https://twitter.com/anvaka) know how it goes. You can also sponsor my projects [here](https://github.com/sponsors/anvaka) - your funds will be dedicated to more awesome and free data visualizations. 44 | 45 | ## Local development 46 | 47 | ``` bash 48 | # install dependencies 49 | npm install 50 | 51 | # serve with hot reload at localhost:8080 52 | npm run dev 53 | 54 | # build for production with minification 55 | npm run build 56 | 57 | # build for production and view the bundle analyzer report 58 | npm run build --report 59 | ``` 60 | 61 | ## License 62 | 63 | The source code is licensed under MIT license 64 | -------------------------------------------------------------------------------- /src/components/vue3-color/common/EditableInput.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 101 | 102 | 115 | -------------------------------------------------------------------------------- /src/components/vue3-color/mixin/color.js: -------------------------------------------------------------------------------- 1 | import tinycolor from 'tinycolor2' 2 | 3 | function _colorChange (data = {}, oldHue = 0) { 4 | const alpha = data && data.a 5 | let color 6 | 7 | // hsl is better than hex between conversions 8 | if (data && data.hsl) { 9 | color = tinycolor(data.hsl) 10 | } else if (data && data.hex && data.hex.length > 0) { 11 | color = tinycolor(data.hex) 12 | } else if (data && data.hsv) { 13 | color = tinycolor(data.hsv) 14 | } else if (data && data.rgba) { 15 | color = tinycolor(data.rgba) 16 | } else if (data && data.rgb) { 17 | color = tinycolor(data.rgb) 18 | } else { 19 | color = tinycolor(data) 20 | } 21 | 22 | if (color && (color._a === undefined || color._a === null)) { 23 | color.setAlpha(alpha || 1) 24 | } 25 | 26 | const hsl = color.toHsl() 27 | const hsv = color.toHsv() 28 | 29 | if (hsl.s === 0) { 30 | hsv.h = hsl.h = data.h || (data.hsl && data.hsl.h) || oldHue || 0 31 | } 32 | 33 | /* --- comment this block to fix #109, may cause #25 again --- */ 34 | // when the hsv.v is less than 0.0164 (base on test) 35 | // because of possible loss of precision 36 | // the result of hue and saturation would be miscalculated 37 | // if (hsv.v < 0.0164) { 38 | // hsv.h = data.h || (data.hsv && data.hsv.h) || 0 39 | // hsv.s = data.s || (data.hsv && data.hsv.s) || 0 40 | // } 41 | 42 | // if (hsl.l < 0.01) { 43 | // hsl.h = data.h || (data.hsl && data.hsl.h) || 0 44 | // hsl.s = data.s || (data.hsl && data.hsl.s) || 0 45 | // } 46 | /* ------ */ 47 | 48 | return { 49 | hsl: hsl, 50 | hex: color.toHexString().toUpperCase(), 51 | hex8: color.toHex8String().toUpperCase(), 52 | rgba: color.toRgb(), 53 | hsv: hsv, 54 | oldHue: data.h || oldHue || hsl.h, 55 | source: data.source, 56 | a: data.a || color.getAlpha() 57 | } 58 | } 59 | 60 | export default { 61 | props: ['modelValue'], 62 | data () { 63 | return { 64 | val: _colorChange(this.modelValue) 65 | } 66 | }, 67 | computed: { 68 | colors: { 69 | get () { 70 | return this.val 71 | }, 72 | set (newVal) { 73 | this.val = newVal 74 | this.$emit('update:modelValue', newVal) 75 | } 76 | } 77 | }, 78 | watch: { 79 | modelValue (newVal) { 80 | this.val = _colorChange(newVal) 81 | } 82 | }, 83 | methods: { 84 | colorChange (data, oldHue) { 85 | this.oldHue = this.colors.hsl.h 86 | this.colors = _colorChange(data, oldHue || this.oldHue) 87 | }, 88 | isValidHex (hex) { 89 | return tinycolor(hex).isValid() 90 | }, 91 | simpleCheckForValidColor (data) { 92 | const keysToCheck = ['r', 'g', 'b', 'a', 'h', 's', 'l', 'v'] 93 | let checked = 0 94 | let passed = 0 95 | 96 | for (let i = 0; i < keysToCheck.length; i++) { 97 | const letter = keysToCheck[i] 98 | if (data[letter]) { 99 | checked++ 100 | if (!isNaN(data[letter])) { 101 | passed++ 102 | } 103 | } 104 | } 105 | 106 | if (checked === passed) { 107 | return data 108 | } 109 | }, 110 | paletteUpperCase (palette) { 111 | return palette.map(c => c.toUpperCase()) 112 | }, 113 | isTransparent (color) { 114 | return tinycolor(color).getAlpha() === 0 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/components/vue3-color/common/Alpha.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 89 | 90 | 135 | -------------------------------------------------------------------------------- /src/lib/LoadOptions.js: -------------------------------------------------------------------------------- 1 | import findBoundaryByName from "./findBoundaryByName.js"; 2 | 3 | /** 4 | * For console API we allow a lot of flexibility to fetch data 5 | * This component normalizes input arguments and turns them into unified 6 | * options object 7 | */ 8 | 9 | export default class LoadOptions { 10 | static parse(scene, wayFilter, rawOptions) { 11 | let result = new LoadOptions(); 12 | if (typeof rawOptions === 'string') { 13 | result.place = rawOptions; 14 | } 15 | 16 | if (wayFilter) { 17 | result.wayFilter = wayFilter; 18 | } 19 | 20 | if (!rawOptions) return result; 21 | 22 | Object.assign(result, rawOptions); 23 | 24 | let protoLayer = getProtoLayer(scene, rawOptions.layer); 25 | if (protoLayer) { 26 | result.projector = protoLayer.getGridProjector(); 27 | let protoQueryBounds = protoLayer.getQueryBounds(); 28 | if (protoQueryBounds && !result.place && !result.areaId && !result.bbox) { 29 | // use bounds of the parent layer unless we have our own override. 30 | result.place = protoQueryBounds.place; 31 | result.areaId = protoQueryBounds.areaId; 32 | result.bbox = protoQueryBounds.bbox; 33 | } 34 | } 35 | 36 | if (rawOptions.projector) { 37 | // user defined projection. See https://github.com/d3/d3-geo for the projector reference: 38 | result.projector = projector; 39 | } 40 | 41 | return result; 42 | } 43 | 44 | constructor(overrides) { 45 | /** 46 | * Query that should be translated to area id by nominatim; 47 | */ 48 | this.place = undefined; 49 | 50 | /** 51 | * Which projector should be used to map lon/lat to layer's x/y 52 | */ 53 | this.projector = undefined; 54 | this.wayFilter = undefined; 55 | this.timeout = 900; 56 | this.maxHeapByteSize = 1073741824; 57 | this.outputMethod = 'skel'; // body 58 | Object.assign(this, overrides); 59 | } 60 | 61 | getQueryTemplate() { 62 | if (this.raw) { 63 | // I assume you know what you are doing. 64 | return Promise.resolve({ 65 | queryString: this.raw 66 | }); 67 | } 68 | 69 | if (!this.wayFilter) { 70 | throw new Error('Way filter is required'); 71 | } 72 | 73 | return this.getBounds() 74 | .then(bounds => { 75 | let queryString; 76 | if (bounds.areaId) { 77 | queryString = `[timeout:${this.timeout}][maxsize:${this.maxHeapByteSize}][out:json]; 78 | area(${bounds.areaId}); 79 | (._; )->.area; 80 | (${this.wayFilter}(area.area); node(w);); 81 | out ${this.outputMethod};`; 82 | } else if (bounds.bbox) { 83 | let bbox = serializeBBox(bounds.bbox); 84 | queryString = `[timeout:${this.timeout}][maxsize:${this.maxHeapByteSize}][bbox:${bbox}][out:json]; 85 | (${this.wayFilter}; node(w);); 86 | out ${this.outputMethod};`; 87 | } 88 | 89 | return { 90 | bounds, 91 | queryString 92 | } 93 | }); 94 | } 95 | 96 | getBounds() { 97 | if (this.place) { 98 | return findBoundaryByName(this.place).then(x => x && x[0]); 99 | } 100 | if (this.areaId) { 101 | return Promise.resolve({ areaId: this.areaId }); 102 | } 103 | if (this.bbox) { 104 | return Promise.resolve({ bbox: this.bbox }); 105 | } 106 | 107 | throw new Error('Please specify bounding area for the query (place|areaId|bbox)'); 108 | } 109 | } 110 | 111 | function getProtoLayer(scene, layerDefinition) { 112 | if (layerDefinition === undefined) return; 113 | 114 | if (typeof layerDefinition === 'number') { 115 | let layers = scene.queryLayerAll(); 116 | return layers[layerDefinition]; 117 | } else if (typeof layerDefinition === 'string') { 118 | return scene.queryLayer(layerDefinition); 119 | } else { 120 | // We assume it is a layer instance: 121 | return layerDefinition; 122 | } 123 | } 124 | 125 | function serializeBBox(bbox) { 126 | return bbox && bbox.join(','); 127 | } -------------------------------------------------------------------------------- /src/lib/Query.js: -------------------------------------------------------------------------------- 1 | import postData from './postData'; 2 | import Grid from './Grid.js'; 3 | import findBoundaryByName from './findBoundaryByName.js'; 4 | 5 | export default class Query { 6 | /** 7 | * Every possible way 8 | */ 9 | static All = 'way'; 10 | 11 | /** 12 | * Every single building 13 | */ 14 | static Building = 'way[building]'; 15 | /** 16 | * This gets anything marked as a highway, which has its own pros and cons. 17 | * See https://github.com/anvaka/city-roads/issues/20 18 | */ 19 | static Road = 'way[highway]'; 20 | 21 | /** 22 | * Reduced set of roads 23 | */ 24 | static RoadBasic = 'way[highway~"^(motorway|primary|secondary|tertiary)|residential"]'; 25 | 26 | /** 27 | * More accurate representation of the roads by @RicoElectrico. 28 | */ 29 | static RoadStrict = 'way[highway~"^(((motorway|trunk|primary|secondary|tertiary)(_link)?)|unclassified|residential|living_street|pedestrian|service|track)$"][area!=yes]'; 30 | 31 | static runFromOptions(loadOptions, progress) { 32 | return loadOptions.getQueryTemplate().then(boundedQuery => { 33 | let q = new Query(boundedQuery, progress); 34 | return q.run(); 35 | }); 36 | } 37 | 38 | constructor(boundedQuery, progress) { 39 | this.queryBounds = boundedQuery.bounds; 40 | this.queryString = boundedQuery.queryString; 41 | this.progress = progress; 42 | this.promise = null; 43 | } 44 | 45 | run() { 46 | if (this.promise) { 47 | return this.promise; 48 | } 49 | let parts = collectAllNominatimQueries(this.queryString); 50 | 51 | this.promise = runAllNominmantimQueries(parts) 52 | .then(resolvedQueryString => postData(resolvedQueryString, this.progress)) 53 | .then(osmResponse => { 54 | let grid = Grid.fromOSMResponse(osmResponse.elements) 55 | grid.queryBounds = this.queryBounds; 56 | return grid; 57 | }); 58 | 59 | return this.promise; 60 | } 61 | } 62 | 63 | function runAllNominmantimQueries(parts) { 64 | let lastProcessed = 0; 65 | 66 | return processNext().then(concat); 67 | 68 | function concat() { 69 | return parts.map(part => { 70 | if (typeof part === 'string') { 71 | return part; 72 | } 73 | if (part.geoType === 'Area') return `area(${part.areaId})`; 74 | if (part.geoType === 'Coords') return part.lat + ',' + part.lon; 75 | if (part.geoType === 'Id') return `${part.osmType}(${part.osmId})`; 76 | if (part.geoType === 'Bbox') return part.bbox.join(','); 77 | 78 | }).join(''); 79 | } 80 | 81 | function processNext() { 82 | if (lastProcessed >= parts.length) { 83 | return Promise.resolve(); 84 | } 85 | 86 | let part = parts[lastProcessed]; 87 | lastProcessed += 1; 88 | if (typeof part === 'string') return processNext(); 89 | 90 | return findBoundaryByName(part.name) 91 | .then(pickFirstBoundary) 92 | .then(first => { 93 | if (!first) { 94 | throw new Error('No areas found for request ' + part.name); 95 | } 96 | Object.assign(part, first); 97 | }) 98 | .then(wait(1000)) // per nominatim agreement we are not allowed to issue more tan 1 request per second 99 | .then(processNext); 100 | } 101 | } 102 | 103 | function pickFirstBoundary(boundaries) { 104 | if (boundaries.length > 0) { 105 | return boundaries[0]; 106 | } 107 | } 108 | 109 | function collectAllNominatimQueries(extendedQuery) { 110 | let geoTest = /{{geocode(.+?):(.+?)}}/; 111 | let match; 112 | let parts = []; 113 | let lastIndex = 0; 114 | while ((match = extendedQuery.match(geoTest))) { 115 | parts.push(extendedQuery.substr(0, match.index)); 116 | parts.push({ 117 | geoType: match[1], 118 | name: match[2] 119 | }); 120 | extendedQuery = extendedQuery.substr(match.index + match[0].length) 121 | } 122 | 123 | parts.push(extendedQuery); 124 | 125 | return parts; 126 | } 127 | 128 | function wait(ms) { 129 | return function(args) { 130 | return new Promise(resolve => { 131 | setTimeout(() => resolve(args), ms); 132 | }); 133 | } 134 | } 135 | 136 | -------------------------------------------------------------------------------- /src/lib/Grid.js: -------------------------------------------------------------------------------- 1 | import BoundingBox from './BoundingBox.js'; 2 | import {geoMercator} from 'd3-geo'; 3 | 4 | /** 5 | * All roads in the area 6 | */ 7 | export default class Grid { 8 | constructor() { 9 | this.elements = []; 10 | this.bounds = new BoundingBox(); 11 | this.nodes = new Map(); 12 | this.wayPointCount = 0; 13 | this.id = 0; 14 | this.name = ''; 15 | this.isArea = true; 16 | this.projector = undefined; 17 | } 18 | 19 | setName(name) { 20 | this.name = name; 21 | } 22 | 23 | setId(id) { 24 | this.id = id; 25 | } 26 | 27 | setIsArea(isArea) { 28 | this.isArea = isArea; 29 | } 30 | 31 | setBBox(bboxString) { 32 | this.bboxString = bboxString; 33 | } 34 | 35 | hasRoads() { 36 | return this.wayPointCount > 0; 37 | } 38 | 39 | setProjector(newProjector) { 40 | this.projector = newProjector; 41 | } 42 | 43 | static fromPBF(pbf) { 44 | if (pbf.version !== 1) throw new Error('Unknown version ' + pbf.version); 45 | let elementsOfOSMResponse = []; 46 | pbf.nodes.forEach(node => { 47 | node.type = 'node'; 48 | elementsOfOSMResponse.push(node) 49 | }); 50 | pbf.ways.forEach(way => { 51 | way.type = 'way'; 52 | elementsOfOSMResponse.push(way); 53 | }); 54 | 55 | const grid = Grid.fromOSMResponse(elementsOfOSMResponse); 56 | grid.setName(pbf.name); 57 | grid.setId(pbf.id); 58 | return grid; 59 | } 60 | 61 | static fromOSMResponse(elementsOfOSMResponse) { 62 | let gridInstance = new Grid(); 63 | 64 | let nodes = gridInstance.nodes; 65 | let bounds = gridInstance.bounds; 66 | let wayPointCount = 0; 67 | 68 | // TODO: async? 69 | elementsOfOSMResponse.forEach(element => { 70 | if (element.type === 'node') { 71 | nodes.set(element.id, element); 72 | bounds.addPoint(element.lon, element.lat); 73 | } else if (element.type === 'way') { 74 | wayPointCount += element.nodes.length; 75 | } 76 | }); 77 | 78 | gridInstance.elements = elementsOfOSMResponse; 79 | gridInstance.wayPointCount = wayPointCount; 80 | return gridInstance; 81 | } 82 | 83 | getProjectedRect() { 84 | let bounds = this.bounds; 85 | let project = this.getProjector(); 86 | let leftTop = project({lon: bounds.left, lat: bounds.bottom}); 87 | let rightBottom = project({lon: bounds.right, lat: bounds.top}); 88 | let left = leftTop.x; 89 | let top = leftTop.y; 90 | let bottom = rightBottom.y 91 | let right = rightBottom.x; 92 | return { 93 | left, top, right, bottom, 94 | width: right - left, height: Math.abs(bottom - top) 95 | } 96 | } 97 | 98 | forEachElement(callback) { 99 | this.elements.forEach(callback); 100 | } 101 | 102 | forEachWay(callback, enter, exit) { 103 | let positions = this.nodes; 104 | let project = this.getProjector(); 105 | this.elements.forEach(element => { 106 | if (element.type !== 'way') return; 107 | 108 | let nodeIds = element.nodes; 109 | let node = positions.get(nodeIds[0]) 110 | if (!node) return; 111 | 112 | let last = project(node); 113 | if (enter) enter(element); 114 | 115 | for (let index = 1; index < nodeIds.length; ++index) { 116 | node = positions.get(nodeIds[index]) 117 | if (!node) continue; 118 | let next = project(node); 119 | 120 | callback(last, next); 121 | 122 | last = next; 123 | } 124 | if (exit) exit(element); 125 | }); 126 | } 127 | 128 | getProjector() { 129 | let q = [0, 0]; // reuse to avoid GC. 130 | 131 | if (!this.projector) { 132 | this.projector = geoMercator(); 133 | this.projector 134 | .center([this.bounds.cx, this.bounds.cy]) 135 | .scale(6371393); // Radius of Earth 136 | } 137 | 138 | let projector = this.projector; 139 | 140 | return project; 141 | 142 | function project({lon, lat}) { 143 | q[0] = lon; q[1] = lat; 144 | 145 | let xyPoint = projector(q); 146 | 147 | return { 148 | x: xyPoint[0], 149 | y: -xyPoint[1] 150 | }; 151 | } 152 | } 153 | } -------------------------------------------------------------------------------- /src/components/vue3-color/common/Saturation.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 104 | 105 | 136 | -------------------------------------------------------------------------------- /src/lib/canvas2BlobPolyfill.js: -------------------------------------------------------------------------------- 1 | /* 2 | * JavaScript Canvas to Blob 3 | * https://github.com/blueimp/JavaScript-Canvas-to-Blob 4 | * 5 | * Copyright 2012, Sebastian Tschan 6 | * https://blueimp.net 7 | * 8 | * Licensed under the MIT license: 9 | * https://opensource.org/licenses/MIT 10 | * 11 | * Based on stackoverflow user Stoive's code snippet: 12 | * http://stackoverflow.com/q/4998908 13 | */ 14 | 15 | /* global define, Uint8Array, ArrayBuffer, module */ 16 | 17 | ;(function(window) { 18 | 'use strict' 19 | 20 | var CanvasPrototype = 21 | window.HTMLCanvasElement && window.HTMLCanvasElement.prototype 22 | var hasBlobConstructor = 23 | window.Blob && 24 | (function() { 25 | try { 26 | return Boolean(new Blob()) 27 | } catch (e) { 28 | return false 29 | } 30 | })() 31 | var hasArrayBufferViewSupport = 32 | hasBlobConstructor && 33 | window.Uint8Array && 34 | (function() { 35 | try { 36 | return new Blob([new Uint8Array(100)]).size === 100 37 | } catch (e) { 38 | return false 39 | } 40 | })() 41 | var BlobBuilder = 42 | window.BlobBuilder || 43 | window.WebKitBlobBuilder || 44 | window.MozBlobBuilder || 45 | window.MSBlobBuilder 46 | var dataURIPattern = /^data:((.*?)(;charset=.*?)?)(;base64)?,/ 47 | var dataURLtoBlob = 48 | (hasBlobConstructor || BlobBuilder) && 49 | window.atob && 50 | window.ArrayBuffer && 51 | window.Uint8Array && 52 | function(dataURI) { 53 | var matches, 54 | mediaType, 55 | isBase64, 56 | dataString, 57 | byteString, 58 | arrayBuffer, 59 | intArray, 60 | i, 61 | bb 62 | // Parse the dataURI components as per RFC 2397 63 | matches = dataURI.match(dataURIPattern) 64 | if (!matches) { 65 | throw new Error('invalid data URI') 66 | } 67 | // Default to text/plain;charset=US-ASCII 68 | mediaType = matches[2] 69 | ? matches[1] 70 | : 'text/plain' + (matches[3] || ';charset=US-ASCII') 71 | isBase64 = !!matches[4] 72 | dataString = dataURI.slice(matches[0].length) 73 | if (isBase64) { 74 | // Convert base64 to raw binary data held in a string: 75 | byteString = atob(dataString) 76 | } else { 77 | // Convert base64/URLEncoded data component to raw binary: 78 | byteString = decodeURIComponent(dataString) 79 | } 80 | // Write the bytes of the string to an ArrayBuffer: 81 | arrayBuffer = new ArrayBuffer(byteString.length) 82 | intArray = new Uint8Array(arrayBuffer) 83 | for (i = 0; i < byteString.length; i += 1) { 84 | intArray[i] = byteString.charCodeAt(i) 85 | } 86 | // Write the ArrayBuffer (or ArrayBufferView) to a blob: 87 | if (hasBlobConstructor) { 88 | return new Blob([hasArrayBufferViewSupport ? intArray : arrayBuffer], { 89 | type: mediaType 90 | }) 91 | } 92 | bb = new BlobBuilder() 93 | bb.append(arrayBuffer) 94 | return bb.getBlob(mediaType) 95 | } 96 | if (window.HTMLCanvasElement && !CanvasPrototype.toBlob) { 97 | if (CanvasPrototype.mozGetAsFile) { 98 | CanvasPrototype.toBlob = function(callback, type, quality) { 99 | var self = this 100 | setTimeout(function() { 101 | if (quality && CanvasPrototype.toDataURL && dataURLtoBlob) { 102 | callback(dataURLtoBlob(self.toDataURL(type, quality))) 103 | } else { 104 | callback(self.mozGetAsFile('blob', type)) 105 | } 106 | }) 107 | } 108 | } else if (CanvasPrototype.toDataURL && dataURLtoBlob) { 109 | CanvasPrototype.toBlob = function(callback, type, quality) { 110 | var self = this 111 | setTimeout(function() { 112 | callback(dataURLtoBlob(self.toDataURL(type, quality))) 113 | }) 114 | } 115 | } 116 | } 117 | if (typeof define === 'function' && define.amd) { 118 | define(function() { 119 | return dataURLtoBlob 120 | }) 121 | } else if (typeof module === 'object' && module.exports) { 122 | module.exports = dataURLtoBlob 123 | } else { 124 | window.dataURLtoBlob = dataURLtoBlob 125 | } 126 | })(window) -------------------------------------------------------------------------------- /src/lib/GridLayer.js: -------------------------------------------------------------------------------- 1 | import config from '../config.js'; 2 | import tinycolor from 'tinycolor2'; 3 | import {WireCollection} from 'w-gl'; 4 | 5 | let counter = 0; 6 | 7 | export default class GridLayer { 8 | get color() { 9 | return this._color; 10 | } 11 | 12 | set color(unsafeColor) { 13 | let color = tinycolor(unsafeColor); 14 | this._color = color; 15 | if (this.lines) { 16 | this.lines.color = toRatioColor(color.toRgb()); 17 | } 18 | if (this.scene) { 19 | this.scene.renderFrame(); 20 | } 21 | } 22 | 23 | get lineWidth() { 24 | return this._lineWidth; 25 | } 26 | 27 | set lineWidth(newValue) { 28 | this._lineWidth = newValue; 29 | if (!this.lines || !this.scene) return; 30 | 31 | this.lines.setLineWidth(newValue); 32 | } 33 | 34 | constructor() { 35 | this._color = config.getDefaultLineColor(); 36 | this.grid = null; 37 | this.lines = null; 38 | this.scene = null; 39 | this.dx = 0; 40 | this.dy = 0; 41 | this.scale = 1; 42 | this.hidden = false; 43 | this.id = 'paths_' + counter; 44 | this._lineWidth = 1; 45 | counter += 1; 46 | } 47 | 48 | getGridProjector() { 49 | if (this.grid) return this.grid.projector; 50 | } 51 | 52 | getQueryBounds() { 53 | const {grid} = this; 54 | if (grid) { 55 | if (grid.queryBounds) return grid.queryBounds; 56 | if (grid.isArea) return { 57 | areaId: grid.id 58 | }; 59 | } 60 | } 61 | 62 | setGrid(grid) { 63 | this.grid = grid; 64 | if (this.scene) { 65 | this.bindToScene(this.scene); 66 | } 67 | } 68 | 69 | getViewBox() { 70 | if (!this.grid) return null; 71 | 72 | let {width, height} = this.grid.getProjectedRect(); 73 | let initialSceneSize = Math.max(width, height) / 4; 74 | return { 75 | left: -initialSceneSize, 76 | top: initialSceneSize, 77 | right: initialSceneSize, 78 | bottom: -initialSceneSize, 79 | }; 80 | } 81 | 82 | moveTo(x, y = 0) { 83 | console.warn('Please use moveBy() instead. The moveTo() is under construction'); 84 | // this.dx = x; 85 | // this.dy = y; 86 | 87 | // this._transferTransform(); 88 | } 89 | 90 | moveBy(dx, dy = 0) { 91 | this.dx = dx; 92 | this.dy = dy; 93 | 94 | this._transferTransform(); 95 | } 96 | 97 | buildLinesCollection() { 98 | if (this.lines) return this.lines; 99 | 100 | let grid = this.grid; 101 | let lines = new WireCollection(grid.wayPointCount, { 102 | width: this._lineWidth, 103 | allowColors: false, 104 | is3D: false 105 | }); 106 | grid.forEachWay(function(from, to) { 107 | lines.add({from, to}); 108 | }); 109 | let color = tinycolor(this._color).toRgb(); 110 | lines.color = toRatioColor(color); 111 | lines.id = this.id; 112 | 113 | this.lines = lines; 114 | } 115 | 116 | destroy() { 117 | if (!this.scene || !this.lines) return; 118 | 119 | // TODO: This should remove the grid layer too. Need to clean up how 120 | // scene interacts with grid layers. 121 | this.scene.removeChild(this.lines); 122 | } 123 | 124 | bindToScene(scene) { 125 | if (this.scene && this.lines) { 126 | console.error('You seem to be adding this layer twice...') 127 | } 128 | 129 | this.scene = scene; 130 | if (!this.grid) return; 131 | 132 | this.buildLinesCollection(); 133 | 134 | if (this.hidden) return; 135 | this.scene.appendChild(this.lines); 136 | } 137 | 138 | hide() { 139 | if (this.hidden) return; 140 | this.hidden = true; 141 | if (!this.scene || !this.grid) return; 142 | 143 | this.scene.removeChild(this.lines); 144 | } 145 | 146 | show() { 147 | if (!this.hidden) return; 148 | this.hidden = false; 149 | if (!this.scene || !this.grid) { 150 | console.log('Layer will be shown when grid is available'); 151 | return; 152 | } 153 | 154 | this.scene.appendChild(this.lines); 155 | } 156 | 157 | _transferTransform() { 158 | if (!this.lines) return; 159 | 160 | this.lines.translate([this.dx, this.dy, 0]); 161 | this.lines.updateWorldTransform(true); 162 | if (this.scene) { 163 | this.scene.renderFrame(true); 164 | } 165 | } 166 | } 167 | 168 | function toRatioColor(c) { 169 | return {r: c.r/0xff, g: c.g/0xff, b: c.b/0xff, a: c.a} 170 | } -------------------------------------------------------------------------------- /src/components/ColorPicker.vue: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 125 | 126 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | # Console API 2 | 3 | *This is work in progress and subject to change. Please don't rely on it for anything critical* 4 | 5 | The `city-roads` provides additional set of operations for the software engineers, allowing them 6 | to execute arbitrary OpenStreetMap queries and visualize results. 7 | 8 | ## Methods 9 | 10 | This section describes available console API methods. 11 | 12 | ### `scene.load()` 13 | 14 | Allows you to load more city roads into the current scene. Before we dive into details, let's explore what 15 | it takes to render Tokyo and Seattle next to each other. 16 | 17 | ![Tokyo and Seattle](./images/tokyo_and_seattle.png) 18 | 19 | First, open [city roads](https://anvaka.github.io/city-roads/) 20 | and load `Seattle` roads. Then open [developer console](https://developers.google.com/web/tools/chrome-devtools/open) and run the following command: 21 | 22 | ``` js 23 | scene.load(Query.Road, 'Tokyo'); // load every single road in Tokyo 24 | ``` 25 | 26 | Monitor your `Networks` tab and see when request is done. Tokyo bounding box is very large, 27 | so it will appear very far away on the top left corner. Let's move Tokyo grid next to Seattle: 28 | 29 | ``` js 30 | // Find the loaded layer with Tokyo: 31 | tokyo = scene.queryLayer('Tokyo'); 32 | 33 | // Exact offset numbers can be found by experimenting 34 | tokyo.moveBy(/* xOffset = */ 718000, /* yOffset = */ 745000) 35 | ``` 36 | 37 | `scene.load()` has the following signature: 38 | 39 | ``` js 40 | function load(wayFilter: String, loadOptions: LoadOptions); 41 | ``` 42 | 43 | * `wayFilter` is used to filter out OpenStreetMap ways. You can find a list of well-known filters [here](https://github.com/anvaka/city-roads/blob/f543a712a0b88b12751aad691baa5eb9d6c0c664/src/lib/Query.js#L6-L24). If you need 44 | to know more to create custom filters, here is a complete [language guide](https://wiki.openstreetmap.org/wiki/Overpass_API/Overpass_QL). You can also get good insight into key/value distribution for ways by exploring [taginfo](https://taginfo.openstreetmap.org/tags) (make sure to sort by Ways in descending order to get the most popular combinations); 45 | * `loadOptions` allows you to have granular control over the bounding box of the loaded results. If this 46 | value is a string, then it is converted to a geocoded area id with nominatim, and then the first match 47 | is used as a bounding box. This may not be enough sometimes, so you can provide a specific area id, or 48 | a bounding box, by passing an object. For example: 49 | 50 | ``` js 51 | scene.load(Query.Road, {areaId: 3600237385}); // Explicitly set area id to Seattle 52 | 53 | scene.load(Query.Building, { // Load all buildings... 54 | bbox: [ // ...in the given bounding box 55 | "-15.8477", /* south lat */ 56 | "-47.9841", /* west lon */ 57 | "-15.7330", /* north lat */ 58 | "-47.7970" /* east lon */ 59 | ]}); 60 | ``` 61 | 62 | ### scene.queryLayerAll() 63 | 64 | Returns all layers added to the scene. This is what it takes to assign different colors to each layer: 65 | 66 | ``` js 67 | allLayers = scene.queryLayerAll() 68 | allLayers[0].color = 'deepskyblue'; // color can be a name. 69 | allLayers[1].color = 'rgb(255, 12, 43)'; // or a any other expression (rgb, hex, hsl, etc.) 70 | ``` 71 | 72 | ### `scene.clear()` 73 | 74 | Clears the current scene, allowing you to start from scratch. 75 | 76 | 77 | ### `scene.saveToPNG(fileName: string)` 78 | 79 | To save the current scene as a PNG file run 80 | 81 | ``` js 82 | scene.saveToPNG('hello'); // hello.png is saved 83 | ``` 84 | 85 | ### `scene.saveToSVG(fileName: string, options?: Object)` 86 | 87 | This command allows you to save the scene as an SVG file. 88 | 89 | ``` js 90 | scene.saveToSVG('hello'); // hello.svg is saved 91 | ``` 92 | 93 | If you are planning to use a pen-plotter or a laser cutter, you can also 94 | greatly reduce the print time, by removing very short paths from the final 95 | export. To do so, pass `minLength` option: 96 | 97 | ``` js 98 | scene.saveToSVG('hello', {minLength: 2}); 99 | // All paths with length shorter than 2px are removed from the final SVG. 100 | ``` 101 | 102 | ## Examples 103 | 104 | Here are a few example of working with the API. 105 | 106 | ### Loading all bikeways in the current city 107 | 108 | ``` js 109 | var bikes = scene.load('way[highway="cycleway"]', {layer: scene.queryLayer()}) 110 | // Make lines 4 pixels wide 111 | bikes.lineWidth = 4 112 | // and red 113 | bikes.color = 'red' 114 | ``` 115 | 116 | ### Loading all bus routes in the current city 117 | 118 | This script will get all bus routes in the current city, and render them 4px wide, with 119 | red color: 120 | 121 | ``` js 122 | var areaId = scene.queryLayer().getQueryBounds().areaId; 123 | var bus = scene.load('', { 124 | layer: scene.queryLayer(), 125 | raw: `[out:json][timeout:250]; 126 | area(${areaId});(._; )->.area; 127 | (nwr[route=bus](area.area);); 128 | out body;>;out skel qt;` 129 | }); 130 | 131 | bus.color='red'; 132 | bus.lineWidth = 4; 133 | ``` 134 | 135 | If you want a specific bus number, pass additional `ref=bus_number`. For example, bus route #24: 136 | 137 | ``` js 138 | var areaId = scene.queryLayer().getQueryBounds().areaId; 139 | var bus = scene.load('', { 140 | layer: scene.queryLayer(), 141 | raw: `[out:json][timeout:250]; 142 | area(${areaId});(._; )->.area; 143 | (nwr[route=bus][ref=24](area.area);); 144 | out body;>;out skel qt;` 145 | }); 146 | 147 | bus.color = 'green'; 148 | bus.lineWidth = 4; 149 | ``` 150 | 151 | -------------------------------------------------------------------------------- /src/lib/saveFile.js: -------------------------------------------------------------------------------- 1 | // import protobufExport from './protobufExport.js'; 2 | import svgExport from './svgExport.js'; 3 | 4 | export function toSVG(scene, options) { 5 | options = options || {}; 6 | let svg = svgExport(scene, { 7 | printable: collectPrintable(), 8 | ...options 9 | }); 10 | let blob = new Blob([svg], {type: "image/svg+xml"}); 11 | let url = window.URL.createObjectURL(blob); 12 | let fileName = getFileName(options.name, '.svg'); 13 | // For some reason, safari doesn't like when download happens on the same 14 | // event loop cycle. Pushing it to the next one. 15 | setTimeout(() => { 16 | let a = document.createElement("a"); 17 | a.href = url; 18 | a.download = fileName; 19 | a.click(); 20 | revokeLater(url); 21 | }, 30) 22 | } 23 | 24 | export function toPNG(scene, options) { 25 | options = options || {}; 26 | 27 | getPrintableCanvas(scene).then((printableCanvas) => { 28 | let fileName = getFileName(options.name, '.png'); 29 | 30 | printableCanvas.toBlob(function(blob) { 31 | let url = window.URL.createObjectURL(blob); 32 | let a = document.createElement("a"); 33 | a.href = url; 34 | a.download = fileName; 35 | a.click(); 36 | revokeLater(url); 37 | }, 'image/png') 38 | }) 39 | } 40 | 41 | export function getPrintableCanvas(scene) { 42 | let cityCanvas = getCanvas(); 43 | let width = cityCanvas.width; 44 | let height = cityCanvas.height; 45 | 46 | let printable = document.createElement('canvas'); 47 | let ctx = printable.getContext('2d'); 48 | printable.width = width; 49 | printable.height = height; 50 | scene.render(); 51 | ctx.drawImage(cityCanvas, 0, 0, cityCanvas.width, cityCanvas.height, 0, 0, width, height); 52 | 53 | return Promise.all(collectPrintable().map(label => drawTextLabel(label, ctx))).then(() => { 54 | return printable; 55 | }); 56 | } 57 | 58 | export function getCanvas() { 59 | return document.querySelector('#canvas') 60 | } 61 | 62 | function getFileName(name, extension) { 63 | let fileName = escapeFileName(name || new Date().toISOString()); 64 | return fileName + (extension || ''); 65 | } 66 | 67 | function escapeFileName(str) { 68 | if (!str) return ''; 69 | 70 | return str.replace(/[#%&{}\\/?*><$!'":@+`|=]/g, '_'); 71 | } 72 | 73 | 74 | function drawTextLabel(element, ctx) { 75 | if (!element) return Promise.resolve(); 76 | 77 | return new Promise((resolve, reject) => { 78 | let dpr = window.devicePixelRatio || 1; 79 | 80 | if (element.element instanceof SVGSVGElement) { 81 | let svg = element.element; 82 | let rect = element.bounds; 83 | let image = new Image(); 84 | image.width = rect.width * dpr; 85 | image.height = rect.height * dpr; 86 | image.onload = () => { 87 | ctx.drawImage(image, rect.left * dpr, rect.top * dpr, image.width, image.height); 88 | svg.removeAttribute('width'); 89 | svg.removeAttribute('height'); 90 | resolve(); 91 | }; 92 | 93 | // Need to set width, otherwise firefox doesn't work: https://stackoverflow.com/questions/28690643/firefox-error-rendering-an-svg-image-to-html5-canvas-with-drawimage 94 | svg.setAttribute('width', image.width); 95 | svg.setAttribute('height', image.height); 96 | image.src = 'data:image/svg+xml;base64,' + btoa(new XMLSerializer().serializeToString(svg)); 97 | } else { 98 | ctx.save(); 99 | 100 | ctx.font = dpr * element.fontSize + 'px ' + element.fontFamily; 101 | ctx.fillStyle = element.color; 102 | ctx.textAlign = 'end' 103 | ctx.fillText( 104 | element.text, 105 | (element.bounds.right - element.paddingRight) * dpr, 106 | (element.bounds.bottom - element.paddingBottom) * dpr 107 | ) 108 | ctx.restore(); 109 | resolve(); 110 | } 111 | }); 112 | } 113 | 114 | function collectPrintable() { 115 | return Array.from(document.querySelectorAll('.printable')).map(element => { 116 | let computedStyle = window.getComputedStyle(element); 117 | let bounds = element.getBoundingClientRect(); 118 | let fontSize = Number.parseInt(computedStyle.fontSize, 10); 119 | let paddingRight = Number.parseInt(computedStyle.paddingRight, 10); 120 | // TODO: I don't know why I need to multiply by 2, it's just 121 | // not aligned right if I don't multiply. Need to figure out this. 122 | let paddingBottom = Number.parseInt(computedStyle.paddingBottom, 10) * 2; 123 | 124 | return { 125 | text: element.innerText, 126 | bounds, 127 | fontSize, 128 | paddingBottom, 129 | paddingRight, 130 | color: computedStyle.color, 131 | fontFamily: computedStyle.fontFamily, 132 | fill: computedStyle.color, 133 | element 134 | } 135 | }); 136 | } 137 | 138 | function revokeLater(url) { 139 | // In iOS immediately revoked URLs cause "WebKitBlobResource error 1." error 140 | // Setting a timeout to revoke URL in the future fixes the error: 141 | setTimeout(() => { 142 | window.URL.revokeObjectURL(url); 143 | }, 45000); 144 | } 145 | 146 | // function toProtobuf() { 147 | // if (!lastGrid) return; 148 | 149 | // let arrayBuffer = protobufExport(lastGrid); 150 | // let blob = new Blob([arrayBuffer.buffer], {type: "application/octet-stream"}); 151 | // let url = window.URL.createObjectURL(blob); 152 | // let a = document.createElement("a"); 153 | // a.href = url; 154 | // a.download = lastGrid.id + '.pbf'; 155 | // a.click(); 156 | // revokeLater(url); 157 | // } 158 | -------------------------------------------------------------------------------- /src/components/vue3-color/common/Hue.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 148 | 149 | 185 | -------------------------------------------------------------------------------- /src/lib/createScene.js: -------------------------------------------------------------------------------- 1 | import bus from './bus'; 2 | import GridLayer from './GridLayer'; 3 | import Query from './Query'; 4 | import LoadOptions from './LoadOptions.js'; 5 | import config from '../config.js'; 6 | import tinycolor from 'tinycolor2'; 7 | import eventify from 'ngraph.events'; 8 | import {toSVG, toPNG} from './saveFile.js'; 9 | import * as wgl from 'w-gl'; 10 | 11 | /** 12 | * This file is responsible for rendering of the grid. It uses my silly 2d webgl 13 | * renderer which is not very well documented, neither popular, yet it is very 14 | * fast. 15 | */ 16 | 17 | export default function createScene(canvas) { 18 | let scene = wgl.createScene(canvas); 19 | let lastLineColor = config.getDefaultLineColor(); 20 | scene.on('transform', triggerTransform); 21 | scene.on('append-child', triggerAdd); 22 | scene.on('remove-child', triggerRemove); 23 | 24 | scene.setClearColor(0xf7/0xff, 0xf2/0xff, 0xe8/0xff, 1.0); 25 | let camera = scene.getCameraController(); 26 | if (camera.setMoveSpeed) { 27 | camera.setMoveSpeed(200); 28 | camera.setRotationSpeed(Math.PI/500); 29 | } 30 | 31 | let gl = scene.getGL(); 32 | gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA); 33 | 34 | let slowDownZoom = false; 35 | let layers = []; 36 | let backgroundColor = config.getBackgroundColor(); 37 | 38 | listenToEvents(); 39 | 40 | let sceneAPI = { 41 | /** 42 | * Requests the scene to perform immediate re-render 43 | */ 44 | render() { 45 | scene.renderFrame(true); 46 | }, 47 | 48 | /** 49 | * Removes all layers in the scene 50 | */ 51 | clear() { 52 | layers.forEach(layer => layer.destroy()); 53 | layers = []; 54 | scene.clear(); 55 | }, 56 | 57 | /** 58 | * Returns all layers in the scene. 59 | */ 60 | queryLayerAll, 61 | 62 | /** 63 | * Same as `queryLayerAll(filter)` but returns the first found 64 | * match. If no matches found - returns undefined. 65 | */ 66 | queryLayer, 67 | 68 | getRenderer() { 69 | return scene; 70 | }, 71 | 72 | getWGL() { 73 | // Let the plugins use the same version of wgl library 74 | return wgl; 75 | }, 76 | 77 | version() { 78 | return '0.0.2'; // here be dragons 79 | }, 80 | 81 | /** 82 | * Destroys the scene, cleans up all resources. 83 | */ 84 | dispose() { 85 | scene.clear(); 86 | scene.dispose(); 87 | sceneAPI.fire('dispose', sceneAPI); 88 | unsubscribeFromEvents(); 89 | }, 90 | 91 | /** 92 | * Uniformly sets color to all loaded grid layer. 93 | */ 94 | set lineColor(color) { 95 | layers.forEach(layer => { 96 | layer.color = color; 97 | }); 98 | lastLineColor = tinycolor(color); 99 | bus.fire('line-color', lastLineColor); 100 | sceneAPI.fire('line-color', lastLineColor); 101 | }, 102 | 103 | get lineColor() { 104 | let firstLayer = queryLayer(); 105 | return (firstLayer && firstLayer.color) || lastLineColor; 106 | }, 107 | 108 | /** 109 | * Sets the background color of the scene 110 | */ 111 | set background(rawColor) { 112 | backgroundColor = tinycolor(rawColor); 113 | let c = backgroundColor.toRgb(); 114 | scene.setClearColor(c.r/0xff, c.g/0xff, c.b/0xff, c.a); 115 | scene.renderFrame(); 116 | bus.fire('background-color', backgroundColor); 117 | sceneAPI.fire('background-color', backgroundColor); 118 | }, 119 | 120 | get background() { 121 | return backgroundColor; 122 | }, 123 | 124 | add, 125 | 126 | /** 127 | * Executes an OverPass query and loads results into scene. 128 | */ 129 | load, 130 | 131 | saveToPNG, 132 | 133 | saveToSVG 134 | }; 135 | 136 | return eventify(sceneAPI); // Public bit is over. Below are just implementation details. 137 | 138 | /** 139 | * Experimental API. Can be changed/removed at any point. 140 | */ 141 | function load(queryFilter, rawOptions) { 142 | let options = LoadOptions.parse(sceneAPI, queryFilter, rawOptions); 143 | 144 | let layer = new GridLayer(); 145 | layer.id = options.place; 146 | 147 | // TODO: Cancellation logic? 148 | Query.runFromOptions(options).then(grid => { 149 | grid.setProjector(options.projector); 150 | layer.setGrid(grid); 151 | }).catch(e => { 152 | console.error(`Could not execute: 153 | ${queryFilter} 154 | The error was:`); 155 | console.error(e); 156 | layer.destroy(); 157 | }); 158 | 159 | add(layer); 160 | return layer; 161 | } 162 | 163 | function queryLayerAll(filter) { 164 | if (!filter) return layers; 165 | 166 | return layers.filter(layer => { 167 | return layer.id === filter; 168 | }); 169 | } 170 | 171 | function queryLayer(filter) { 172 | let result = queryLayerAll(filter); 173 | if (result) return result[0]; 174 | } 175 | 176 | function add(gridLayer) { 177 | if (layers.indexOf(gridLayer) > -1) return; // O(n). 178 | 179 | gridLayer.bindToScene(scene); 180 | layers.push(gridLayer); 181 | 182 | if (layers.length === 1) { 183 | // TODO: Should I do this for other layers? 184 | let viewBox = gridLayer.getViewBox(); 185 | if (viewBox) { 186 | scene.setViewBox(viewBox); 187 | } 188 | } 189 | } 190 | 191 | function saveToPNG(name) { 192 | return toPNG(sceneAPI, {name}); 193 | } 194 | 195 | function saveToSVG(name, options) { 196 | return toSVG(sceneAPI, Object.assign({}, {name}, options)); 197 | } 198 | 199 | function triggerTransform(t) { 200 | bus.fire('scene-transform'); 201 | } 202 | 203 | function triggerAdd(e) { 204 | sceneAPI.fire('layer-added', e); 205 | } 206 | 207 | function triggerRemove(e) { 208 | sceneAPI.fire('layer-removed', e); 209 | } 210 | 211 | function listenToEvents() { 212 | document.addEventListener('keydown', onKeyDown, true); 213 | document.addEventListener('keyup', onKeyUp, true); 214 | } 215 | 216 | function unsubscribeFromEvents() { 217 | document.removeEventListener('keydown', onKeyDown, true); 218 | document.removeEventListener('keyup', onKeyUp, true); 219 | } 220 | 221 | function onKeyDown(e) { 222 | if (e.shiftKey) { 223 | slowDownZoom = true; 224 | if (camera.setSpeed) camera.setSpeed(0.1); 225 | } 226 | } 227 | 228 | function onKeyUp(e) { 229 | if (!e.shiftKey && slowDownZoom) { 230 | if (camera.setSpeed) camera.setSpeed(1); 231 | slowDownZoom = false; 232 | } 233 | } 234 | } -------------------------------------------------------------------------------- /src/createOverlayManager.js: -------------------------------------------------------------------------------- 1 | export default function createOverlayManager() { 2 | let overlay; 3 | let downEvent = { 4 | clickedElement: null, 5 | x: 0, 6 | y: 0, 7 | time: Date.now(), 8 | left: 0, 9 | right: 0 10 | }; 11 | 12 | document.addEventListener('mousedown', handleMouseDown); 13 | document.addEventListener('mouseup', handleMouseUp); 14 | document.addEventListener('touchstart', handleTouchStart, {passive: false, capture: true}); 15 | document.addEventListener('touchend', handleTouchEnd, true); 16 | document.addEventListener('touchcancel', handleTouchEnd, true); 17 | 18 | return { 19 | track, 20 | dispose, 21 | clear 22 | } 23 | 24 | function clear() { 25 | const activeOverlays = document.querySelectorAll('.overlay-active'); 26 | for (let i = 0; i < activeOverlays.length; ++i) { 27 | deselect(activeOverlays[i]); 28 | } 29 | } 30 | 31 | function handleMouseDown(e) { 32 | onPointerDown(e.clientX, e.clientY, e); 33 | } 34 | 35 | function handleMouseMove(e) { 36 | onPointerMove(e.clientX, e.clientY); 37 | } 38 | 39 | function handleMouseUp(e) { 40 | onPointerUp(e.clientX, e.clientY) 41 | } 42 | 43 | function handleTouchStart(e) { 44 | if (e.touches.length > 1) return; 45 | 46 | let touch = e.touches[0]; 47 | onPointerDown(touch.clientX, touch.clientY, e); 48 | } 49 | 50 | function handleTouchEnd(e) { 51 | if (e.changedTouches.length > 1) return; 52 | let touch = e.changedTouches[0]; 53 | let gotSomethingSelected = onPointerUp(touch.clientX, touch.clientY); 54 | if (gotSomethingSelected) { 55 | e.preventDefault(); 56 | e.stopPropagation(); 57 | } 58 | } 59 | 60 | function handleTouchMove(e) { 61 | if (e.touches.length > 1) return; 62 | let touch = e.touches[0]; 63 | onPointerMove(touch.clientX, touch.clientY); 64 | e.preventDefault(); 65 | e.stopPropagation(); 66 | } 67 | 68 | function onPointerDown(x, y, e) { 69 | let foundElement = findTrackedElementUnderCursor(x, y) 70 | let activeOverlays = document.querySelectorAll('.overlay-active'); 71 | 72 | for (let i = 0; i < activeOverlays.length; ++i) { 73 | let el = activeOverlays[i]; 74 | if (el !== foundElement) deselect(el); 75 | } 76 | if (activeOverlays.length === 1) downEvent.clickedElement = activeOverlays[0]; 77 | 78 | let secondTimeClicking = foundElement && foundElement === downEvent.clickedElement; 79 | if (secondTimeClicking) { 80 | if (!downEvent.clickedElement.contains(e.target)) { 81 | foundElement = null; 82 | secondTimeClicking = false; 83 | } 84 | } 85 | let shouldAddOverlay = secondTimeClicking && !foundElement.classList.contains('exclusive'); 86 | if (shouldAddOverlay) { 87 | // prepare for move! 88 | addDragOverlay(); 89 | e.preventDefault(); 90 | e.stopPropagation(); 91 | } else { 92 | downEvent.clickedElement = foundElement; 93 | } 94 | 95 | downEvent.x = x; 96 | downEvent.y = y; 97 | downEvent.time = Date.now(); 98 | if (foundElement) { 99 | let bBox = foundElement.getBoundingClientRect(); 100 | downEvent.dx = bBox.right - downEvent.x; 101 | downEvent.dy = bBox.bottom - downEvent.y; 102 | } else { 103 | clear(); 104 | } 105 | } 106 | 107 | function onPointerUp(x, y) { 108 | if (!downEvent.clickedElement) return; 109 | removeOverlay(); 110 | 111 | if (isSingleClick(x, y)) { 112 | // forward focus, we didn't move the element 113 | select(downEvent.clickedElement, x, y); 114 | return true; 115 | } else { 116 | downEvent.clickedElement = null; 117 | } 118 | } 119 | 120 | function onPointerMove(x, y) { 121 | if (!downEvent.clickedElement) return; 122 | 123 | let style = downEvent.clickedElement.style; 124 | style.right = 100*(window.innerWidth - x - downEvent.dx)/window.innerWidth + '%'; 125 | style.bottom = 100*(window.innerHeight - y - downEvent.dy)/window.innerHeight + '%'; 126 | } 127 | 128 | function addDragOverlay() { 129 | removeOverlay(); 130 | 131 | overlay = document.createElement('div'); 132 | overlay.classList.add('drag-overlay'); 133 | document.body.appendChild(overlay); 134 | 135 | document.addEventListener('mousemove', handleMouseMove, true); 136 | document.addEventListener('touchmove', handleTouchMove, {passive: false, capture: true}); 137 | } 138 | 139 | function removeOverlay() { 140 | if (overlay) { 141 | document.body.removeChild(overlay); 142 | overlay = null; 143 | } 144 | 145 | document.removeEventListener('mousemove', handleMouseMove, true); 146 | document.removeEventListener('touchmove', handleTouchMove, {passive: false, capture: true}); 147 | } 148 | 149 | function isSingleClick(x, y) { 150 | let timeDiff = Date.now() - downEvent.time; 151 | if (timeDiff > 300) return false; // took too long for a single click; 152 | 153 | // should release roughly in the same place where pressed: 154 | return Math.hypot(x - downEvent.x, y - downEvent.y) < 40; 155 | } 156 | 157 | function findTrackedElementUnderCursor(x, y) { 158 | let autoTrack = document.querySelectorAll('.can-drag'); 159 | for (let i = 0; i < autoTrack.length; ++i) { 160 | let el = autoTrack[i]; 161 | let rect = getRectangle(el); 162 | if (intersects(x, y, rect)) return el; 163 | } 164 | } 165 | 166 | function deselect(el) { 167 | el.style.pointerEvents = 'none'; 168 | el.classList.remove('overlay-active'); 169 | el.classList.remove('exclusive') 170 | } 171 | 172 | function select(el, x, y) { 173 | if (!el) return; 174 | 175 | el.style.pointerEvents = ''; 176 | 177 | if (el.classList.contains('overlay-active')) { 178 | // When they click second time, we want to forward focus to the element 179 | // (if they support focus forwarding) 180 | if (el.receiveFocus) el.receiveFocus(); 181 | // and make the element exclusive owner of the mouse/pointer 182 | // (so that native interaction can occur and we don't interfere with dragging) 183 | el.classList.add('exclusive') 184 | } else { 185 | // When they click first time, we enter to "drag around" mode 186 | el.classList.add('overlay-active'); 187 | if (el.classList.contains('can-resize')) { 188 | // el.resizer = renderResizeHandlers(el); 189 | } 190 | } 191 | } 192 | 193 | 194 | function intersects(x, y, rect) { 195 | return !(x < rect.left || x > rect.right || y < rect.top || y > rect.bottom); 196 | } 197 | 198 | function getRectangle(x) { 199 | return x.getBoundingClientRect(); 200 | } 201 | 202 | function track(domElement, options) { 203 | domElement.style.pointerEvents = 'none' 204 | domElement.classList.add('can-drag'); 205 | 206 | if (options) { 207 | if (options.receiveFocus) domElement.receiveFocus = options.receiveFocus; 208 | } 209 | } 210 | 211 | function dispose() { 212 | document.removeEventListener('mousedown', handleMouseDown); 213 | document.removeEventListener('mouseup', handleMouseUp); 214 | document.removeEventListener('touchstart', handleTouchStart); 215 | document.removeEventListener('touchend', handleTouchEnd); 216 | document.removeEventListener('touchcancel', handleTouchEnd); 217 | downEvent.clickedElement = undefined; 218 | removeOverlay(); 219 | } 220 | } 221 | 222 | function renderResizeHandlers(el) { 223 | el.getBoundingClientRect(el) 224 | } -------------------------------------------------------------------------------- /src/components/vue3-color/Sketch.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 149 | 150 | 280 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 76 | 77 | 296 | 297 | 506 | -------------------------------------------------------------------------------- /src/components/FindPlace.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 338 | 339 | 514 | --------------------------------------------------------------------------------