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