├── .gitignore ├── dist ├── sample.xlsx ├── flowmap.1.4.9.pbiviz ├── flowmap.1.0.0.0.pbiviz ├── flowmap.1.1.3.0.pbiviz ├── flowmap.1.1.4.0.pbiviz ├── flowmap.1.2.0.0.pbiviz ├── flowmap.1.2.1.0.pbiviz ├── flowmap.1.2.1.1.pbiviz ├── flowmap.1.2.2.0.pbiviz ├── flowmap.1.2.3.0.pbiviz ├── flowmap.1.2.4.0.pbiviz ├── flowmap.1.2.5.0.pbiviz ├── flowmap.1.2.6.0.pbiviz ├── flowmap.1.3.0.0.pbiviz ├── flowmap.1.3.1.0.pbiviz ├── flowmap.1.3.2.0.pbiviz ├── flowmap.1.3.3.0.pbiviz ├── flowmap.1.3.5.0.pbiviz ├── flowmap.1.3.6.0.pbiviz ├── flowmap.1.4.0.0.pbiviz ├── flowmap.1.4.1.0.pbiviz ├── flowmap.1.4.3.0.pbiviz └── flowmap.1.4.8.0.pbiviz ├── docs ├── assets │ ├── 0.jpg │ ├── 1.jpg │ ├── 2.jpg │ └── 3.jpg ├── _data │ └── sliders.yml ├── _config.yml ├── privacy.md ├── _includes │ ├── slider_styles.html │ ├── slider.html │ ├── head.html │ ├── disqus_comments.html │ └── slider_scripts.html ├── _layouts │ └── default.html ├── lib │ ├── js │ │ └── slider │ │ │ ├── iis-captions.js │ │ │ └── iis-bullet-nav.js │ └── css │ │ └── slider │ │ ├── ideal-image-slider.css │ │ └── themes │ │ └── default.css └── index.md ├── code ├── assets │ ├── icon.png │ ├── icon1.png │ ├── screenshot.png │ ├── screenshot1.png │ ├── screenshot2.png │ ├── screenshot3.png │ └── icon.svg ├── src │ ├── pbi │ │ ├── index.ts │ │ ├── misc.ts │ │ ├── tooltip.ts │ │ ├── Category.ts │ │ ├── Persist.ts │ │ ├── Roles.ts │ │ ├── numberFormat.ts │ │ ├── Context.ts │ │ └── Format.ts │ ├── lava │ │ ├── bingmap │ │ │ ├── index.ts │ │ │ ├── geoQuery.ts │ │ │ ├── jsonp.ts │ │ │ ├── converter.ts │ │ │ └── geoService.ts │ │ ├── vector.ts │ │ ├── flowmap │ │ │ ├── util.ts │ │ │ ├── config.ts │ │ │ ├── pin.ts │ │ │ ├── popup.ts │ │ │ ├── pie.ts │ │ │ ├── flow.ts │ │ │ ├── banner.ts │ │ │ ├── shape.ts │ │ │ ├── app.ts │ │ │ ├── arc.ts │ │ │ └── legend.ts │ │ └── type.ts │ ├── global.js │ ├── flowmap │ │ └── format.ts │ └── visual.ts ├── .vscode │ ├── launch.json │ └── settings.json ├── tsconfig.json ├── package.json ├── pbiviz.json ├── tslint.json └── style │ └── visual.less ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .tmp 3 | .bundle 4 | Default EULA.pdf -------------------------------------------------------------------------------- /dist/sample.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/sample.xlsx -------------------------------------------------------------------------------- /docs/assets/0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/docs/assets/0.jpg -------------------------------------------------------------------------------- /docs/assets/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/docs/assets/1.jpg -------------------------------------------------------------------------------- /docs/assets/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/docs/assets/2.jpg -------------------------------------------------------------------------------- /docs/assets/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/docs/assets/3.jpg -------------------------------------------------------------------------------- /code/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/code/assets/icon.png -------------------------------------------------------------------------------- /code/assets/icon1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/code/assets/icon1.png -------------------------------------------------------------------------------- /code/assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/code/assets/screenshot.png -------------------------------------------------------------------------------- /dist/flowmap.1.4.9.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.4.9.pbiviz -------------------------------------------------------------------------------- /code/assets/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/code/assets/screenshot1.png -------------------------------------------------------------------------------- /code/assets/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/code/assets/screenshot2.png -------------------------------------------------------------------------------- /code/assets/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/code/assets/screenshot3.png -------------------------------------------------------------------------------- /dist/flowmap.1.0.0.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.0.0.0.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.1.3.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.1.3.0.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.1.4.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.1.4.0.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.2.0.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.2.0.0.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.2.1.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.2.1.0.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.2.1.1.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.2.1.1.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.2.2.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.2.2.0.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.2.3.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.2.3.0.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.2.4.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.2.4.0.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.2.5.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.2.5.0.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.2.6.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.2.6.0.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.3.0.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.3.0.0.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.3.1.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.3.1.0.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.3.2.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.3.2.0.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.3.3.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.3.3.0.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.3.5.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.3.5.0.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.3.6.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.3.6.0.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.4.0.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.4.0.0.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.4.1.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.4.1.0.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.4.3.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.4.3.0.pbiviz -------------------------------------------------------------------------------- /dist/flowmap.1.4.8.0.pbiviz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weiweicui/PowerBI-Flowmap/HEAD/dist/flowmap.1.4.8.0.pbiviz -------------------------------------------------------------------------------- /code/src/pbi/index.ts: -------------------------------------------------------------------------------- 1 | export { Persist } from './Persist'; 2 | export { Context } from './Context'; 3 | export { tooltip } from './tooltip'; 4 | 5 | export type Fill = { solid: {color: string} } -------------------------------------------------------------------------------- /code/src/lava/bingmap/index.ts: -------------------------------------------------------------------------------- 1 | export { ILocation, IBound, Converter, IArea } from './converter'; 2 | export { GeoQuery } from './geoQuery'; 3 | export { MapFormat, Controller, IListener, pixel } from './controller'; -------------------------------------------------------------------------------- /code/src/global.js: -------------------------------------------------------------------------------- 1 | __lavaBuildMap = null; 2 | __geocode_jsonp0 = null; 3 | __geocode_jsonp1 = null; 4 | __geocode_jsonp2 = null; 5 | __geocode_jsonp3 = null; 6 | __geocode_jsonp4 = null; 7 | __geocode_jsonp5 = null; 8 | __geocode_jsonp6 = null; 9 | -------------------------------------------------------------------------------- /docs/_data/sliders.yml: -------------------------------------------------------------------------------- 1 | - selector: slider1 2 | bullets: true 3 | captions: true 4 | images: 5 | - src: /assets/0.jpg 6 | - src: /assets/1.jpg 7 | - src: /assets/2.jpg 8 | - src: /assets/3.jpg 9 | settings: 10 | height: 300 11 | effect: "'slide'" 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Flowmap custom visual for PowerBI 2 | 3 | * Please find the plugin files in the **dist** folder. 4 | * Please find the source code in the **code** folder. 5 | * `npm run start` to activate the custom visual. 6 | * Need more info/help? Please visit [here](https://weiweicui.github.io/PowerBI-Flowmap). 7 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | title: "" 2 | 3 | # Config settings 4 | baseurl: "PowerBI-Flowmap" 5 | 6 | # Build settings 7 | theme: minima 8 | 9 | 10 | # checklist: 11 | # a. _data/sliders.yml: change the images 12 | # b. index.md: title and content 13 | 14 | disqus: weiweicui-flowmap 15 | name: https://weiweicui.github.io/PowerBI-Flowmap/ -------------------------------------------------------------------------------- /code/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.0", 3 | "configurations": [ 4 | { 5 | "name": "Debugger", 6 | "type": "chrome", 7 | "request": "attach", 8 | "port": 9222, 9 | "sourceMaps": true, 10 | "webRoot": "${cwd}/" 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /docs/privacy.md: -------------------------------------------------------------------------------- 1 | # Privacy Statement 2 | We do not collect or store your information. However, if latitude/longitude are not provided in the Fields, we may transmit your content in the fields of `Origin` and `Destination` via [*Microsoft Bing Maps REST Services*](https://msdn.microsoft.com/en-us/library/ff701715.aspx) to obtain the corresponding geo-coordinates. If you wish __not__ to transmit any information, you need to specifically provide the latitudes and longitudes in the Fields. 3 | -------------------------------------------------------------------------------- /code/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "emitDecoratorMetadata": true, 5 | "experimentalDecorators": true, 6 | "target": "es6", 7 | "sourceMap": true, 8 | "outDir": "./.tmp/build/", 9 | "moduleResolution": "node", 10 | "declaration": true, 11 | "lib": [ 12 | "es2015", 13 | "dom" 14 | ] 15 | }, 16 | "files": [ 17 | "src/visual.ts" 18 | ] 19 | } -------------------------------------------------------------------------------- /docs/_includes/slider_styles.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% if page.image_sliders or layout.image_sliders or page.image_sliders_load_all %} 5 | 6 | 7 | 8 | 9 | {% endif %} 10 | -------------------------------------------------------------------------------- /docs/_includes/slider.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% if include.selector != empty %} 5 | {% assign slider = site.data.sliders | where:"selector",include.selector | first %} 6 |
7 | {% for image in slider.images %} 8 | {% if image.href %}{% endif %}{{ image.alt }}{% if image.href %}{% endif %} 9 | {% endfor %} 10 |
11 | {% endif %} 12 | -------------------------------------------------------------------------------- /docs/_includes/head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% if page.title %}{{ page.title | escape }}{% else %}{{ site.title | escape }}{% endif %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | {% include slider_styles.html %} 14 | 15 | -------------------------------------------------------------------------------- /code/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "visual", 3 | "scripts": { 4 | "pbiviz": "pbiviz", 5 | "start": "pbiviz start", 6 | "package": "pbiviz package", 7 | "lint": "tslint -r \"node_modules/tslint-microsoft-contrib\" \"+(src|test)/**/*.ts\"" 8 | }, 9 | "dependencies": { 10 | "@babel/runtime": "7.6.0", 11 | "@babel/runtime-corejs2": "7.6.0", 12 | "@types/bingmaps": "0.0.1", 13 | "@types/clone": "0.1.30", 14 | "@types/d3": "5.7.2", 15 | "clone": "2.1.2", 16 | "core-js": "3.2.1", 17 | "d3": "5.12.0", 18 | "deepmerge": "4.2.2", 19 | "fast-deep-equal": "3.1.3", 20 | "powerbi-visuals-api": "~2.6.1", 21 | "powerbi-visuals-utils-tooltiputils": "2.4.0" 22 | }, 23 | "devDependencies": { 24 | "ts-loader": "6.1.0", 25 | "tslint": "^5.18.0", 26 | "tslint-microsoft-contrib": "^6.2.0", 27 | "typescript": "3.6.3" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /code/src/lava/vector.ts: -------------------------------------------------------------------------------- 1 | import { IPoint } from './type'; 2 | 3 | export function unit(e1: IPoint, e2: IPoint): [number, number] { 4 | let a = e2.x - e1.x, b = e2.y - e1.y; 5 | let s = Math.sqrt(a * a + b * b); 6 | return [a / s, b / s]; 7 | } 8 | 9 | export function scale(e1: IPoint, e2: IPoint, scale: number): [number, number] { 10 | let a = e2.x - e1.x, b = e2.y - e1.y; 11 | return [a * scale, b * scale]; 12 | } 13 | 14 | export function create(e1: IPoint, e2: IPoint): [number, number] { 15 | return [e2.x - e1.x, e2.y - e1.y]; 16 | } 17 | 18 | export function length(e1: IPoint, e2: IPoint, len: number): [number, number] { 19 | let a = e2.x - e1.x, b = e2.y - e1.y; 20 | let s = Math.sqrt(a * a + b * b); 21 | return [a / s * len, b / s * len]; 22 | } 23 | 24 | export function norm(vec: [number, number]): number { 25 | return Math.sqrt(vec[0] * vec[0] + vec[1] * vec[1]); 26 | } -------------------------------------------------------------------------------- /code/pbiviz.json: -------------------------------------------------------------------------------- 1 | { 2 | "visual": { 3 | "name": "flowmap", 4 | "displayName": "Flow map", 5 | "guid": "flowmap30CFDD5B92F848C88242B1E81C8C33C7", 6 | "visualClassName": "Visual", 7 | "version": "1.4.9", 8 | "description": "Flowmap is a diagram that visualizes the movement of objects from one location to another.", 9 | "supportUrl": "https://weiweicui.github.io/PowerBI-Flowmap/", 10 | "gitHubUrl": "https://github.com/weiweicui/PowerBI-Flowmap/" 11 | }, 12 | "apiVersion": "2.6.0", 13 | "author": { 14 | "name": "Weiwei Cui", 15 | "email": "weiweicui@outlook.com" 16 | }, 17 | "assets": { 18 | "icon": "assets/icon.png" 19 | }, 20 | "externalJS": ["./src/global.js"], 21 | "style": "style/visual.less", 22 | "capabilities": "capabilities.json", 23 | "dependencies": null, 24 | "stringResources": [] 25 | } -------------------------------------------------------------------------------- /docs/_includes/disqus_comments.html: -------------------------------------------------------------------------------- 1 |
2 | 20 | 21 | -------------------------------------------------------------------------------- /code/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 4, 3 | "editor.insertSpaces": true, 4 | "files.eol": "\n", 5 | "files.watcherExclude": { 6 | "**/.git/objects/**": true, 7 | "**/node_modules/**": true, 8 | ".tmp": true 9 | }, 10 | "files.exclude": { 11 | ".tmp": true 12 | }, 13 | "search.exclude": { 14 | ".tmp": true, 15 | "typings": true 16 | }, 17 | "json.schemas": [ 18 | { 19 | "fileMatch": [ 20 | "/pbiviz.json" 21 | ], 22 | "url": "./node_modules/powerbi-visuals-api/schema.pbiviz.json" 23 | }, 24 | { 25 | "fileMatch": [ 26 | "/capabilities.json" 27 | ], 28 | "url": "./node_modules/powerbi-visuals-api/schema.capabilities.json" 29 | }, 30 | { 31 | "fileMatch": [ 32 | "/dependencies.json" 33 | ], 34 | "url": "../node_modules/powerbi-visuals-api/schema.dependencies.json" 35 | } 36 | ] 37 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Weiwei Cui 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 | -------------------------------------------------------------------------------- /code/src/lava/bingmap/geoQuery.ts: -------------------------------------------------------------------------------- 1 | import { ILocation } from './converter'; 2 | import { Func } from '../type'; 3 | import * as geo from './geoService'; 4 | 5 | export class GeoQuery { 6 | private _addrs = null as string[]; 7 | constructor(addrs: ReadonlyArray) { 8 | this._addrs = addrs.slice(0); 9 | } 10 | 11 | cancel() { 12 | this._addrs = null; 13 | } 14 | 15 | count() { 16 | return this._addrs.length; 17 | } 18 | 19 | run(each: Func): this { 20 | var callback = (loc: ILocation) => { 21 | if (this._addrs) { 22 | each(loc); 23 | } 24 | if (this._addrs && this._addrs.length > 0) { 25 | var addr = this._addrs.shift(); 26 | geo.query(addr, callback); 27 | } 28 | }; 29 | for (var i = 0; i < geo.settings.MaxBingRequest; i++) { 30 | if (this._addrs && this._addrs.length > 0) { 31 | var addr = this._addrs.shift(); 32 | geo.query(addr, callback); 33 | } 34 | } 35 | return this; 36 | } 37 | } -------------------------------------------------------------------------------- /code/src/pbi/misc.ts: -------------------------------------------------------------------------------- 1 | import { Context } from './Context'; 2 | import { StringMap, clamp } from '../lava/type'; 3 | import { ILocation } from '../lava/bingmap'; 4 | 5 | export function coords(data: Context, key: R, lat: R, lon: R, locs?: StringMap): StringMap { 6 | locs = locs || {}; 7 | if (!data.cat(key) || !data.cat(lat) || !data.cat(lon)) { 8 | return locs; 9 | } 10 | const lats = data.cat(lat).data as number[]; 11 | const lons = data.cat(lon).data as number[]; 12 | const keys = data.cat(key).data as string[]; 13 | const bad = (v: number) => isNaN(v) || v === null || v === undefined; 14 | for (const r of data.rows()) { 15 | const addr = keys[r]; 16 | if (addr in locs) { 17 | continue; 18 | } 19 | const lon = +lons[r], lat = +lats[r]; 20 | if (bad(lon) || bad(lat)) { 21 | continue; 22 | } 23 | locs[addr] = { 24 | longitude: clamp(lon, -180, 180), 25 | latitude: clamp(lat, -85.05112878, 85.05112878), 26 | type: 'injected', 27 | name: addr, 28 | address: addr 29 | }; 30 | } 31 | return locs; 32 | } -------------------------------------------------------------------------------- /docs/_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% include head.html %} 5 | 6 | 7 | 8 | {% include header.html %} 9 | 10 |
11 |
12 | {{ content }} 13 | 14 | {% if page.image_sliders or layout.image_sliders or page.image_sliders_load_all %} 15 |
16 | 29 | 30 | {% endif %} 31 |
32 |
33 | 34 | {% include slider_scripts.html %} 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /code/src/pbi/tooltip.ts: -------------------------------------------------------------------------------- 1 | import { Selection } from 'd3-selection'; 2 | import powerbi from "powerbi-visuals-api"; 3 | import { Context } from '.'; 4 | import * as pbi from 'powerbi-visuals-utils-tooltiputils'; 5 | 6 | type Any = Selection; 7 | type VisualTooltipDataItem = powerbi.extensibility.VisualTooltipDataItem; 8 | type Args = pbi.TooltipEventArgs; 9 | 10 | export module tooltip { 11 | let service = null as pbi.ITooltipServiceWrapper; 12 | 13 | export function init(options: powerbi.extensibility.visual.VisualConstructorOptions) { 14 | service = pbi.createTooltipServiceWrapper(options.host.tooltipService, options.element); 15 | } 16 | 17 | export function add(selection: Any, getTooltipInfoDelegate: (args: Args) => VisualTooltipDataItem[]) { 18 | service.addTooltip(selection, getTooltipInfoDelegate); 19 | } 20 | 21 | export function hide() { 22 | service.hide(); 23 | } 24 | 25 | export function item(value: any, displayName?: string, header?: string, color?: string): VisualTooltipDataItem { 26 | return { value, displayName, header, color }; 27 | } 28 | 29 | export function build(ctx: Context, role: T, row: number) { 30 | const columns = ctx.columns(role); 31 | if (!columns || !columns.length) { 32 | return null; 33 | } 34 | return columns.map(c => item(c.values[row], c.source.displayName)); 35 | } 36 | } -------------------------------------------------------------------------------- /docs/lib/js/slider/iis-captions.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Ideal Image Slider: Captions Extension v1.0.1 3 | * 4 | * By Gilbert Pellegrom 5 | * http://gilbert.pellegrom.me 6 | * 7 | * Copyright (C) 2014 Dev7studios 8 | * https://raw.githubusercontent.com/gilbitron/Ideal-Image-Slider/master/LICENSE 9 | */ 10 | 11 | (function(IIS) { 12 | "use strict"; 13 | 14 | IIS.Slider.prototype.addCaptions = function() { 15 | IIS._addClass(this._attributes.container, 'iis-has-captions'); 16 | 17 | Array.prototype.forEach.call(this._attributes.slides, function(slide, i) { 18 | var caption = document.createElement('div'); 19 | IIS._addClass(caption, 'iis-caption'); 20 | 21 | var captionContent = ''; 22 | if (slide.getAttribute('title')) { 23 | captionContent += '
' + slide.getAttribute('title') + '
'; 24 | } 25 | if (slide.getAttribute('data-caption')) { 26 | var dataCaption = slide.getAttribute('data-caption'); 27 | if (dataCaption.substring(0, 1) == '#' || dataCaption.substring(0, 1) == '.') { 28 | var external = document.querySelector(dataCaption); 29 | if (external) { 30 | captionContent += '
' + external.innerHTML + '
'; 31 | } 32 | } else { 33 | captionContent += '
' + slide.getAttribute('data-caption') + '
'; 34 | } 35 | } else { 36 | if (slide.innerHTML) { 37 | captionContent += '
' + slide.innerHTML + '
'; 38 | } 39 | } 40 | 41 | slide.innerHTML = ''; 42 | if (captionContent) { 43 | caption.innerHTML = captionContent; 44 | slide.appendChild(caption); 45 | } 46 | }.bind(this)); 47 | }; 48 | 49 | return IIS; 50 | 51 | })(IdealImageSlider); -------------------------------------------------------------------------------- /code/src/flowmap/format.ts: -------------------------------------------------------------------------------- 1 | import { MapFormat } from '../lava/bingmap'; 2 | import { Setting } from '../pbi/numberFormat'; 3 | 4 | export class Format { 5 | legend = { 6 | show: true, 7 | position: 'top' as 'top' | 'bot', 8 | fontSize: 12, 9 | color: true, 10 | width: true, 11 | color_default: false, 12 | width_default: false, 13 | color_label: '', 14 | width_label: '' 15 | }; 16 | 17 | style = { 18 | style: null as 'straight' | 'flow' | 'arc',//depends 19 | direction: 'out' as 'in' | 'out', 20 | limit: 5 21 | }; 22 | 23 | width = { 24 | customize: true, 25 | item: 2, 26 | scale: 'linear' as 'linear' | 'log' | 'none', 27 | min: 2, 28 | max: 10, 29 | unit: null as number//depends 30 | }; 31 | 32 | color = { 33 | item: { solid: { color: '#01B8AA' } }, 34 | min: { solid: { color: '#99e3dd' } }, 35 | max: { solid: { color: '#015c55' } }, 36 | autofill: false, 37 | customize: true 38 | }; 39 | 40 | advance = { 41 | cache: true, 42 | relocate: false, 43 | located: true, 44 | unlocated: true 45 | }; 46 | 47 | mapControl = MapFormat.control(new MapFormat(), { autoFit: true }); 48 | 49 | mapElement = MapFormat.element(new MapFormat(), {}); 50 | 51 | valueFormat = new ValueFormat(); 52 | 53 | bubble = { 54 | for: null as 'none' | 'origin' | 'dest' | 'both',//depends 55 | slice: null as boolean,//depends 56 | bubbleColor: { solid: { color: '#888888' } }, 57 | scale: 25, 58 | label: 'none' as 'none' | 'all' | 'manual' | 'hide', 59 | labelOpacity: 50, 60 | labelColor: { solid: { color: '#888888' } } 61 | }; 62 | } 63 | 64 | class ValueFormat extends Setting { 65 | sort = 'des' as 'asc' | 'des'; 66 | top = 10; 67 | } -------------------------------------------------------------------------------- /code/src/lava/flowmap/util.ts: -------------------------------------------------------------------------------- 1 | import { format } from 'd3-format'; 2 | import { Config } from './config'; 3 | import { Func } from '../type'; 4 | 5 | export function top(rows: number[], cfg: Config): number[] { 6 | const { sort, top } = cfg.numberSorter; 7 | const width = cfg.weight && cfg.weight.conv; 8 | if (width) { 9 | if (sort === 'des') { 10 | rows.sort((a, b) => width(b) - width(a)); 11 | } 12 | else { 13 | rows.sort((a, b) => width(a) - width(b)); 14 | } 15 | } 16 | return +top >= rows.length ? rows : rows.slice(0, +top); 17 | } 18 | 19 | export function weighter(cfg: Config): Func { 20 | if (cfg.weight) { 21 | return r => Math.max(cfg.weight.conv(r), 0); 22 | } 23 | else { 24 | return r => 1; 25 | } 26 | } 27 | 28 | export function rgb(hex: string) { 29 | let n = parseInt(hex.substr(1), 16); 30 | return [(n >> 16 & 0xff), (n >> 8 & 0xff), (n & 0xff)]; 31 | } 32 | 33 | export function bad(v: number): boolean { 34 | return isNaN(v) || v === null || v === undefined || v === Number.POSITIVE_INFINITY || v === Number.NEGATIVE_INFINITY; 35 | } 36 | 37 | export function nice(values: number[]): string[] { 38 | if (values.some(v => bad(v))) { 39 | return values.map(v => 'n/a'); 40 | } 41 | if (values[0] === values[values.length - 1]) { 42 | let result = values.map(v => v + ''); 43 | if (result[0].length < 4) { 44 | return result; 45 | } 46 | } 47 | for (let i = 1; i < 6; i++) { 48 | let f = format('.' + i + 's'), j = 1; 49 | let result = values.map(v => f(v)); 50 | for (; j < values.length; j++) { 51 | if (result[j - 1] === result[j]) { 52 | break; 53 | } 54 | } 55 | if (j === values.length || i === 5) { 56 | return result; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /code/src/lava/flowmap/config.ts: -------------------------------------------------------------------------------- 1 | import { StringMap } from "../type"; 2 | import { ILocation, MapFormat } from "../bingmap"; 3 | import { Setting } from "../../pbi/numberFormat"; 4 | 5 | type Func = (i: number) => T; 6 | export class Config { 7 | error = null as string; 8 | advance = { 9 | relocate: false, 10 | located: true, 11 | unlocated: true 12 | }; 13 | style = null as 'straight' | 'flow' | 'arc'; 14 | source = null as Func; 15 | target = null as Func; 16 | groups = null as number[][]; 17 | color = null as Func & { min?: string, max?: string };//row=>value || row=>color 18 | weight = null as 19 | { conv: Func; min: number; max: number; scale: 'linear' | 'log'; } | 20 | { conv: Func; unit: number; scale: 'none' } | 21 | { conv: Func; scale: null } 22 | popup = { 23 | description: null as Func, 24 | origin: null as Func, 25 | destination: null as Func 26 | }; 27 | legend = { 28 | show: false, 29 | fontSize: 12, 30 | position: 'top' as 'top' | 'bottom', 31 | color: true, 32 | width: true, 33 | colorLabels: {} as StringMap, 34 | widthLabels: {} as StringMap 35 | }; 36 | 37 | map = new MapFormat(); 38 | 39 | bubble = { 40 | for: null as 'none' | 'origin' | 'dest' | 'both',//depends 41 | slice: null as boolean,//depends 42 | bubbleColor: { solid: { color: '#888888' } }, 43 | scale: 25, 44 | label: 'none' as 'none' | 'all' | 'manual' | 'hide', 45 | labelOpacity: 50, 46 | labelColor: { solid: { color: '#888888' } }, 47 | in: null as Func, 48 | out: null as Func 49 | }; 50 | 51 | injections = {} as StringMap; 52 | 53 | numberSorter = { sort: 'des' as 'asc' | 'des', top: 10 } 54 | 55 | numberFormat = new Setting(); 56 | } 57 | -------------------------------------------------------------------------------- /docs/lib/js/slider/iis-bullet-nav.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Ideal Image Slider: Bullet Navigation Extension v1.0.2 3 | * 4 | * By Gilbert Pellegrom 5 | * http://gilbert.pellegrom.me 6 | * 7 | * Copyright (C) 2014 Dev7studios 8 | * https://raw.githubusercontent.com/gilbitron/Ideal-Image-Slider/master/LICENSE 9 | */ 10 | 11 | (function(IIS) { 12 | "use strict"; 13 | 14 | var _updateActiveBullet = function(slider, activeIndex) { 15 | var bullets = slider._attributes.bulletNav.querySelectorAll('a'); 16 | if (!bullets) return; 17 | 18 | Array.prototype.forEach.call(bullets, function(bullet, i) { 19 | IIS._removeClass(bullet, 'iis-bullet-active'); 20 | bullet.setAttribute('aria-selected', 'false'); 21 | if (i === activeIndex) { 22 | IIS._addClass(bullet, 'iis-bullet-active'); 23 | bullet.setAttribute('aria-selected', 'true'); 24 | } 25 | }.bind(this)); 26 | }; 27 | 28 | IIS.Slider.prototype.addBulletNav = function() { 29 | IIS._addClass(this._attributes.container, 'iis-has-bullet-nav'); 30 | 31 | // Create bullet nav 32 | var bulletNav = document.createElement('div'); 33 | IIS._addClass(bulletNav, 'iis-bullet-nav'); 34 | bulletNav.setAttribute('role', 'tablist'); 35 | 36 | // Create bullets 37 | Array.prototype.forEach.call(this._attributes.slides, function(slide, i) { 38 | var bullet = document.createElement('a'); 39 | bullet.innerHTML = i + 1; 40 | bullet.setAttribute('role', 'tab'); 41 | 42 | bullet.addEventListener('click', function() { 43 | if (IIS._hasClass(this._attributes.container, this.settings.classes.animating)) return false; 44 | this.stop(); 45 | this.gotoSlide(i + 1); 46 | }.bind(this)); 47 | 48 | bulletNav.appendChild(bullet); 49 | }.bind(this)); 50 | 51 | this._attributes.bulletNav = bulletNav; 52 | this._attributes.container.appendChild(bulletNav); 53 | _updateActiveBullet(this, 0); 54 | 55 | // Hook up to afterChange events 56 | var origAfterChange = this.settings.afterChange; 57 | var afterChange = function() { 58 | var slides = this._attributes.slides, 59 | index = slides.indexOf(this._attributes.currentSlide); 60 | _updateActiveBullet(this, index); 61 | return origAfterChange(); 62 | }.bind(this); 63 | this.settings.afterChange = afterChange; 64 | }; 65 | 66 | return IIS; 67 | 68 | })(IdealImageSlider); -------------------------------------------------------------------------------- /code/src/visual.ts: -------------------------------------------------------------------------------- 1 | import "core-js/stable"; 2 | import "./../style/visual.less"; 3 | import powerbi from "powerbi-visuals-api"; 4 | import VisualConstructorOptions = powerbi.extensibility.visual.VisualConstructorOptions; 5 | import VisualUpdateOptions = powerbi.extensibility.visual.VisualUpdateOptions; 6 | import IVisual = powerbi.extensibility.visual.IVisual; 7 | import EnumerateVisualObjectInstancesOptions = powerbi.EnumerateVisualObjectInstancesOptions; 8 | import VisualObjectInstance = powerbi.VisualObjectInstance; 9 | import VisualObjectInstanceEnumerationObject = powerbi.VisualObjectInstanceEnumerationObject; 10 | 11 | import { Visual as Flowmap } from './flowmap/visual'; 12 | // import { selex } from "./lava/d3"; 13 | // import { Controller } from "./lava/bingmap"; 14 | // export class Visual implements IVisual { 15 | // constructor(options: VisualConstructorOptions) { 16 | // const root = selex(options.element); 17 | // const pane = root.append('div').att.id('debugpane').sty.width('100%').sty.position('relative'); 18 | // root.append('div').att.id('view').sty.width('100%').sty.height('100%'); 19 | // const ctl = new Controller('#view').restyle({}).add({ 20 | // transform: (c, z, e) => { 21 | // pane.text(' zoom = ' + z + '...'+JSON.stringify(ctl.map.getZoomRange())); 22 | // } 23 | // }); 24 | // setTimeout(() => { 25 | // try { 26 | // ctl.map.setView({ 27 | // zoom: 2 28 | // }); 29 | // } catch (error) { 30 | // pane.text(' rrr = ' + error + ''); 31 | // } 32 | // }, 5000); 33 | // } 34 | // public update(options: VisualUpdateOptions) { 35 | // } 36 | // } 37 | 38 | export class Visual implements IVisual { 39 | private _visual: IVisual; 40 | constructor(options: VisualConstructorOptions) { 41 | this._visual = new Flowmap(options); 42 | } 43 | 44 | public update(options: VisualUpdateOptions) { 45 | this._visual.update(options); 46 | } 47 | 48 | public enumerateObjectInstances(options: EnumerateVisualObjectInstancesOptions): VisualObjectInstance[] | VisualObjectInstanceEnumerationObject { 49 | return this._visual.enumerateObjectInstances(options); 50 | } 51 | } -------------------------------------------------------------------------------- /code/src/lava/bingmap/jsonp.ts: -------------------------------------------------------------------------------- 1 | import { Func } from '../type'; 2 | 3 | //hardcode 7 global variables to handle jsonp 4 | declare var __geocode_jsonp0; 5 | declare var __geocode_jsonp1; 6 | declare var __geocode_jsonp2; 7 | declare var __geocode_jsonp3; 8 | declare var __geocode_jsonp4; 9 | declare var __geocode_jsonp5; 10 | declare var __geocode_jsonp6; 11 | 12 | export namespace jsonp { 13 | let head: HTMLHeadElement; 14 | let ids = [0, 1, 2, 3, 4, 5, 6]; 15 | let queue = [] as { url: string, then: Func }[]; 16 | function load(url, errorHandler, id, then: Func) { 17 | let script = document.createElement('script'), isLoaded = false; 18 | script.src = url; 19 | script.async = true; 20 | 21 | if (typeof errorHandler === 'function') { 22 | script.onerror = e => then(null); 23 | } 24 | script.onload = () => { 25 | if (isLoaded) { 26 | return; 27 | } 28 | isLoaded = true; 29 | if (queue.length > 0) { 30 | var q = queue.shift(); 31 | setup(id, q.url, q.then); 32 | } 33 | else { 34 | ids.push(id); 35 | } 36 | script.onload = null; 37 | script && script.parentNode && script.parentNode.removeChild(script); 38 | }; 39 | head = head || document.getElementsByTagName('head')[0]; 40 | head.appendChild(script); 41 | } 42 | 43 | function setup(id, url, then) { 44 | if (id === 0) { __geocode_jsonp0 = then; } 45 | else if (id === 1) { __geocode_jsonp1 = then; } 46 | else if (id === 2) { __geocode_jsonp2 = then; } 47 | else if (id === 3) { __geocode_jsonp3 = then; } 48 | else if (id === 4) { __geocode_jsonp4 = then; } 49 | else if (id === 5) { __geocode_jsonp5 = then; } 50 | else if (id === 6) { __geocode_jsonp6 = then; } 51 | load(url + '&jsonp=__geocode_jsonp' + id, null, id, then); 52 | } 53 | 54 | export function get(url, then): void { 55 | if (ids.length === 0) { 56 | queue.push({ 57 | url: url, 58 | then: function (data) { then(data); } 59 | }); 60 | } 61 | else { 62 | setup(ids.shift(), url, function (data) { then(data); }); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /code/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "class-name": true, 4 | "comment-format": [ 5 | true, 6 | "check-space" 7 | ], 8 | "indent": [ 9 | true, 10 | "spaces" 11 | ], 12 | "no-duplicate-variable": true, 13 | "no-eval": true, 14 | "no-internal-module": false, 15 | "no-trailing-whitespace": true, 16 | "no-unsafe-finally": true, 17 | "no-var-keyword": true, 18 | "one-line": [ 19 | true, 20 | "check-open-brace", 21 | "check-whitespace" 22 | ], 23 | "quotemark": [ 24 | false, 25 | "double" 26 | ], 27 | "semicolon": [ 28 | true, 29 | "always" 30 | ], 31 | "triple-equals": [ 32 | true, 33 | "allow-null-check" 34 | ], 35 | "typedef-whitespace": [ 36 | true, 37 | { 38 | "call-signature": "nospace", 39 | "index-signature": "nospace", 40 | "parameter": "nospace", 41 | "property-declaration": "nospace", 42 | "variable-declaration": "nospace" 43 | } 44 | ], 45 | "variable-name": [ 46 | true, 47 | "ban-keywords" 48 | ], 49 | "whitespace": [ 50 | true, 51 | "check-branch", 52 | "check-decl", 53 | "check-operator", 54 | "check-separator", 55 | "check-type" 56 | ], 57 | "insecure-random": true, 58 | "no-banned-terms": true, 59 | "no-cookies": true, 60 | "no-delete-expression": true, 61 | "no-disable-auto-sanitization": true, 62 | "no-document-domain": true, 63 | "no-document-write": true, 64 | "no-exec-script": true, 65 | "no-function-constructor-with-string-args": true, 66 | "no-http-string": [true, "http://www.example.com/?.*", "http://www.examples.com/?.*"], 67 | "no-inner-html": true, 68 | "no-octal-literal": true, 69 | "no-reserved-keywords": true, 70 | "no-string-based-set-immediate": true, 71 | "no-string-based-set-interval": true, 72 | "no-string-based-set-timeout": true, 73 | "non-literal-require": true, 74 | "possible-timing-attack": true, 75 | "react-anchor-blank-noopener": true, 76 | "react-iframe-missing-sandbox": true, 77 | "react-no-dangerous-html": true 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /docs/lib/css/slider/ideal-image-slider.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Ideal Image Slider v1.5.1 3 | * 4 | * By Gilbert Pellegrom 5 | * http://gilbert.pellegrom.me 6 | * 7 | * Copyright (C) 2014 Dev7studios 8 | * https://raw.githubusercontent.com/gilbitron/Ideal-Image-Slider/master/LICENSE 9 | */ 10 | 11 | .ideal-image-slider { 12 | position: relative; 13 | overflow: hidden; 14 | } 15 | .iis-slide { 16 | display: block; 17 | bottom: 0; 18 | text-decoration: none; 19 | position: absolute; 20 | top: 0; 21 | left: 0; 22 | width: 100%; 23 | height: 100%; 24 | background-repeat: no-repeat; 25 | background-position: 50% 50%; 26 | background-size: cover; 27 | text-indent: -9999px; 28 | } 29 | 30 | /* Slide effect */ 31 | .iis-effect-slide .iis-slide { 32 | opacity: 0; 33 | -webkit-transition-property: -webkit-transform; 34 | -moz-transition-property: -moz-transform; 35 | -o-transition-property: -o-transform; 36 | transition-property: transform; 37 | -webkit-transition-timing-function: ease-out; 38 | -moz-transition-timing-function: ease-out; 39 | -o-transition-timing-function: ease-out; 40 | transition-timing-function: ease-out; 41 | -webkit-transform: translateX(0%); 42 | -ms-transform: translateX(0%); 43 | transform: translateX(0%); 44 | } 45 | .iis-effect-slide .iis-current-slide { 46 | opacity: 1; 47 | z-index: 1; 48 | } 49 | .iis-effect-slide .iis-previous-slide { 50 | -webkit-transform: translateX(-100%); 51 | -ms-transform: translateX(-100%); 52 | transform: translateX(-100%); 53 | } 54 | .iis-effect-slide .iis-next-slide { 55 | -webkit-transform: translateX(100%); 56 | -ms-transform: translateX(100%); 57 | transform: translateX(100%); 58 | } 59 | .iis-effect-slide.iis-direction-next .iis-previous-slide, 60 | .iis-effect-slide.iis-direction-previous .iis-next-slide { opacity: 1; } 61 | 62 | /* Touch styles */ 63 | .iis-touch-enabled .iis-slide { z-index: 1; } 64 | .iis-touch-enabled .iis-current-slide { z-index: 2; } 65 | .iis-touch-enabled.iis-is-touching .iis-previous-slide, 66 | .iis-touch-enabled.iis-is-touching .iis-next-slide { opacity: 1; } 67 | 68 | /* Fade effect */ 69 | .iis-effect-fade .iis-slide { 70 | -webkit-transition-property: opacity; 71 | -moz-transition-property: opacity; 72 | -o-transition-property: opacity; 73 | transition-property: opacity; 74 | -webkit-transition-timing-function: ease-in; 75 | -moz-transition-timing-function: ease-in; 76 | -o-transition-timing-function: ease-in; 77 | transition-timing-function: ease-in; 78 | opacity: 0; 79 | } 80 | .iis-effect-fade .iis-current-slide { 81 | opacity: 1; 82 | z-index: 1; 83 | } 84 | -------------------------------------------------------------------------------- /docs/_includes/slider_scripts.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% assign iis_slider_array = "" | split: "" %} 5 | {% for selector in page.image_sliders %} 6 | {% assign iis_slider_array = iis_slider_array | push: selector | uniq %} 7 | {% endfor %} 8 | {% for selector in layout.image_sliders %} 9 | {% assign iis_slider_array = iis_slider_array | push: selector | uniq %} 10 | {% endfor %} 11 | {% if page.image_sliders_load_all == true %} 12 | {% for slider in site.data.sliders %} 13 | {% assign iis_slider_array = iis_slider_array | push: slider.selector | uniq %} 14 | {% endfor %} 15 | {% endif %} 16 | {% if iis_slider_array != empty %} 17 | 18 | 19 | 20 | 66 | {% endif %} 67 | -------------------------------------------------------------------------------- /code/style/visual.less: -------------------------------------------------------------------------------- 1 | p { 2 | font-size: 20px; 3 | font-weight: bold; 4 | em { 5 | background: yellow; 6 | padding: 5px; 7 | 8 | } 9 | } 10 | 11 | svg { 12 | pointer-events: none; 13 | } 14 | 15 | .glyph { 16 | cursor: pointer; 17 | pointer-events: visiblePainted; 18 | } 19 | 20 | 21 | .MicrosoftMap, svg { 22 | cursor: default; 23 | } 24 | 25 | 26 | 27 | .pin path { 28 | stroke:#999; 29 | fill:#F2C811; 30 | } 31 | 32 | .pin.dirty path { 33 | fill:#f08300; 34 | } 35 | .pin.invalid path{ 36 | fill:#c0c6c9; 37 | } 38 | 39 | .pin path:hover { 40 | stroke: black; 41 | } 42 | 43 | svg .flow { 44 | cursor: pointer; 45 | } 46 | 47 | .tip { 48 | pointer-events: none; 49 | position: absolute; 50 | display: flex; 51 | .arrow { 52 | width:0px; 53 | height:0px; 54 | border-style: solid; 55 | border-width: 7px; 56 | border-color: transparent; 57 | } 58 | .content-wrap { 59 | padding: 7px; 60 | font-size: 11px; 61 | } 62 | } 63 | .tip.top { 64 | flex-direction: column-reverse; 65 | } 66 | .tip.bottom { 67 | flex-direction: column; 68 | } 69 | .tip.left { 70 | flex-direction: row-reverse; 71 | } 72 | .tip.right { 73 | flex-direction: row; 74 | } 75 | 76 | .war { 77 | pointer-events: none; 78 | font-size: 11px; 79 | .header { 80 | font-weight: bold; 81 | padding-bottom: 4px; 82 | font-size: 12px; 83 | color: darkred; 84 | } 85 | .row { 86 | display: table-row; 87 | } 88 | .sect { 89 | display: table-cell; 90 | text-transform: uppercase; 91 | padding-right: 10px; 92 | max-width: 200px; 93 | } 94 | .value { 95 | display: table-cell; 96 | max-width: 260px; 97 | } 98 | } 99 | 100 | .info { 101 | pointer-events: none; 102 | font-size: 11px; 103 | .header { 104 | font-weight: bold; 105 | padding-left: 1px; 106 | padding-bottom: 4px; 107 | max-width: 240px; 108 | letter-spacing: 1px; 109 | word-wrap: break-word; 110 | } 111 | .row { 112 | display: table-row; 113 | } 114 | .cell { 115 | display: table-cell; 116 | max-width: 240px; 117 | vertical-align: middle; 118 | padding-top: 1px; 119 | padding-bottom: 1px; 120 | } 121 | .color { 122 | max-width: 13px; 123 | padding: 1px 4px 1px 0px; 124 | svg { 125 | height:14px; 126 | } 127 | } 128 | .title { 129 | font-family: helvetica, arial, sans-serif; 130 | text-transform: uppercase; 131 | padding-top: 3px; 132 | padding-right: 12px; 133 | } 134 | .value { 135 | font-family: helvetica, arial, sans-serif; 136 | font-weight: bold; 137 | white-space: nowrap; 138 | text-overflow: ellipsis; 139 | overflow: hidden; 140 | } 141 | } -------------------------------------------------------------------------------- /code/src/pbi/Category.ts: -------------------------------------------------------------------------------- 1 | import powerbi from "powerbi-visuals-api"; 2 | import { StringMap, buildLabels, NumberMap } from '../lava/type'; 3 | 4 | type PColumn = powerbi.DataViewCategoryColumn | powerbi.DataViewValueColumn; 5 | 6 | export class Category { 7 | public readonly column: PColumn; 8 | private _distincts = null as number[]; 9 | private _host: powerbi.extensibility.visual.IVisualHost; 10 | constructor(column: PColumn, host: powerbi.extensibility.visual.IVisualHost) { 11 | this.column = column; 12 | this._host = host; 13 | if (column) { 14 | let values = column.values; 15 | this.key = r => values[r] + ""; 16 | } 17 | else { 18 | this.key = r => ""; 19 | } 20 | } 21 | 22 | public get type() { 23 | return this.column.source.type; 24 | } 25 | 26 | public get data(): powerbi.PrimitiveValue[] { 27 | return this.column.values; 28 | } 29 | 30 | public selector(row: number): powerbi.data.Selector { 31 | return this._host.createSelectionIdBuilder() 32 | .withCategory(this.column as powerbi.DataViewCategoryColumn, row) 33 | .createSelectionId() 34 | .getSelector(); 35 | } 36 | 37 | public distincts(rows?: number[]): number[] { 38 | if (this._distincts && !rows) { 39 | return this._distincts; 40 | } 41 | if (!this.column) { 42 | return this._distincts = []; 43 | } 44 | let cache = {} as StringMap; 45 | let unique = [] as number[]; 46 | if (rows) { 47 | for (let row of rows) { 48 | let key = this.key(row); 49 | if (!(key in cache)) { 50 | cache[key] = true; 51 | unique.push(+row); 52 | } 53 | } 54 | return unique; 55 | } 56 | else { 57 | for (let row = 0; row < this.column.values.length; row++) { 58 | const key = this.key(row); 59 | if (!(key in cache)) { 60 | cache[key] = true; 61 | unique.push(row); 62 | } 63 | } 64 | return this._distincts = unique; 65 | } 66 | } 67 | 68 | public readonly key: (r: number) => string; 69 | 70 | public row2label(rows: number[]): NumberMap { 71 | if (!rows || rows.length === 0) { 72 | return {}; 73 | } 74 | const labels = this.labels(rows), result = {} as NumberMap; 75 | for (let i = 0; i < rows.length; i++) { 76 | result[rows[i]] = labels[i] as string; 77 | } 78 | return result; 79 | } 80 | 81 | public labels(rows: number[]): string[] { 82 | if (!rows || rows.length === 0) { 83 | return []; 84 | } 85 | if (this.column.source.type.dateTime) { 86 | return buildLabels(rows.map(r => this.column.values[r])); 87 | } 88 | else { 89 | return rows.map(r => this.column.values[r] + ""); 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /code/src/pbi/Persist.ts: -------------------------------------------------------------------------------- 1 | import powerbi from "powerbi-visuals-api"; 2 | 3 | export class Persist { 4 | public static HOST = null as powerbi.extensibility.visual.IVisualHost; 5 | private static _all = [] as Persist[]; 6 | public static update(view: powerbi.DataView): boolean { 7 | //cannot use _all.some(...), we have to updated every one of them 8 | var dirty = false; 9 | for (var v of Persist._all) { 10 | if (v._updated(view)) { 11 | dirty = true; 12 | } 13 | } 14 | return dirty; 15 | } 16 | 17 | constructor(public oname: string, public pname: string) { 18 | Persist._all.push(this); 19 | } 20 | 21 | private _handler = undefined as number; 22 | private _text = undefined as string; 23 | private _updated(view: powerbi.DataView): boolean { 24 | const oname = this.oname, pname = this.pname; 25 | const objects = (view && view.metadata && view.metadata.objects); 26 | if (objects && objects[oname] && objects[oname][pname]) { 27 | const text = objects[oname][pname] as string; 28 | if (text !== this._text) { 29 | //true is a short cut, and skip the entire update 30 | const dirty = this._text !== undefined; 31 | try { 32 | this._text = text; 33 | this._value = JSON.parse(text); 34 | return dirty; 35 | } catch (e1) { 36 | if (text.length > 13) { 37 | //try to use old version of encoding 38 | try { 39 | this._text = text.slice(13); 40 | this._value = JSON.parse(this._text); 41 | } catch (e2) { 42 | this._text = null; 43 | return false; 44 | } 45 | } 46 | else { 47 | //safe to ignore 48 | this._text = null; 49 | return false; 50 | } 51 | } 52 | } 53 | else { 54 | return false; 55 | } 56 | } 57 | this._text = null; 58 | return false; 59 | } 60 | 61 | public value(deft?: T): T { 62 | if (this._value === null && deft !== undefined) { 63 | return this._value = deft; 64 | } 65 | return this._value; 66 | } 67 | 68 | private _value = null as T; 69 | 70 | public write(value: T, delay: number) { 71 | this._value = value; 72 | let oname = this.oname, pname = this.pname; 73 | if (this._handler) { 74 | clearTimeout(this._handler); 75 | } 76 | this._handler = window.setTimeout(() => { 77 | if (value) { 78 | // console.log(`merging persist: ${oname}=>${pname}`); 79 | Persist.HOST.persistProperties({ 80 | merge: [{ 81 | objectName: oname, 82 | properties: { [pname]: JSON.stringify(value) }, 83 | selector: null 84 | }] 85 | }); 86 | } 87 | else { 88 | // console.log(`removing persist: ${oname}=>${pname}`); 89 | Persist.HOST.persistProperties({ 90 | remove: [{ 91 | objectName: oname, 92 | properties: { [pname]: null }, 93 | selector: null 94 | }] 95 | }); 96 | } 97 | this._handler = null; 98 | }, delay); 99 | } 100 | } -------------------------------------------------------------------------------- /code/src/pbi/Roles.ts: -------------------------------------------------------------------------------- 1 | import { StringMap, keys, values, toDate } from '../lava/type'; 2 | import powerbi from 'powerbi-visuals-api'; 3 | 4 | type PColumn = powerbi.DataViewCategoryColumn | powerbi.DataViewValueColumn; 5 | 6 | function index(columns: PColumn[]): StringMap { 7 | let result = {} as StringMap; 8 | if (!columns) { 9 | return result; 10 | } 11 | else { 12 | for (let col of columns) { 13 | for (let role of keys(col.source.roles)) { 14 | if (result[role]) { 15 | result[role].push(col); 16 | } 17 | else { 18 | result[role] = [col]; 19 | } 20 | } 21 | } 22 | return result; 23 | } 24 | } 25 | 26 | export class Roles { 27 | 28 | private _cmap = {} as StringMap; 29 | private _vmap = {} as StringMap; 30 | 31 | update(view: powerbi.DataView): this { 32 | if (!view || !view.categorical) { 33 | this._vmap = this._cmap = {}; 34 | } 35 | else { 36 | let cmap = index(view.categorical.categories); 37 | let vmap = index(view.categorical.values); 38 | this._cmap = cmap; 39 | this._vmap = vmap; 40 | } 41 | return this; 42 | } 43 | 44 | exist(...roles: R[]): boolean { 45 | return roles.every(r => r in this._cmap || r in this._vmap); 46 | } 47 | 48 | column(r?: R): PColumn { 49 | if (r === undefined) { 50 | return values(this._cmap)[0][0]; 51 | } 52 | else { 53 | return (this.columns(r) || [])[0]; 54 | } 55 | } 56 | 57 | 58 | columns(r: R): PColumn[] { 59 | return this._cmap[r] || this._vmap[r]; 60 | } 61 | 62 | sorter(role: R): (r1: number, r2: number) => number { 63 | if (!this.exist(role)) { 64 | return () => 0; 65 | } 66 | let cmps = this.columns(role).map(c => this._sorter(c)); 67 | if (cmps.length === 1) { 68 | return cmps[0]; 69 | } 70 | else { 71 | return (r1, r2) => { 72 | for (let cmp of cmps) { 73 | let ret = cmp(r1, r2); 74 | if (ret !== 0) { 75 | return ret; 76 | } 77 | } 78 | return 0; 79 | } 80 | } 81 | } 82 | 83 | // private 84 | private _sorter(column: PColumn): (r1: number, r2: number) => number { 85 | const values = column.values; 86 | const nullCompare = (va: any, vb: any) => { 87 | if (va === null || va === undefined) { 88 | return -1; 89 | } 90 | if (vb === null || vb === undefined) { 91 | return 1; 92 | } 93 | return undefined; 94 | }; 95 | const build = customCompare => { 96 | return (ra: number, rb: number) => { 97 | const va = values[ra], vb = values[rb]; 98 | return nullCompare(va, vb) || customCompare(va, vb); 99 | } 100 | } 101 | if (column.source.type.numeric) { 102 | return build((a, b) => a - b); 103 | } 104 | else if (column.source.type.dateTime) { 105 | return build((a, b) => { 106 | const da = toDate(a), db = toDate(b); 107 | return nullCompare(da, db) || da.getTime() - db.getTime(); 108 | }); 109 | } 110 | else { 111 | return build((a, b) => (a + '').localeCompare(b + '')); 112 | } 113 | } 114 | } -------------------------------------------------------------------------------- /code/assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 43 | 45 | 46 | 48 | image/svg+xml 49 | 51 | 52 | 53 | 54 | 55 | 60 | 66 | 72 | 78 | 84 | 90 | 96 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /code/src/lava/flowmap/pin.ts: -------------------------------------------------------------------------------- 1 | import { IListener, Controller, ILocation } from '../bingmap'; 2 | import { StringMap, IPoint, Func} from "../type"; 3 | import { drag } from 'd3-drag'; 4 | import { ISelex, selex } from "../d3"; 5 | import { event as d3event } from 'd3-selection'; 6 | 7 | import { $state } from './app'; 8 | 9 | export const events = { 10 | onDrag: null as (addr: string, loc: ILocation) => void 11 | } 12 | 13 | export function init(d3: ISelex): IListener { 14 | root = d3; 15 | return { 16 | transform(ctl: Controller) { 17 | root.selectAll('.pin.valid').att.translate(addr => ctl.pixel($state.loc(addr))); 18 | }, 19 | resize(_) { resize(); } 20 | } 21 | } 22 | 23 | 24 | function resize() { 25 | var length = root.selectAll('.pin.invalid').size(); 26 | if (length === 0) { 27 | return; 28 | } 29 | var pixer = _placer(length); 30 | root.selectAll('.pin.invalid').att.translate((_, i) => pixer(i)); 31 | } 32 | 33 | let root: ISelex; 34 | let groups = {} as StringMap; 35 | 36 | export function clear() { 37 | groups = {}; 38 | root.selectAll('*').remove(); 39 | } 40 | 41 | let _drag = drag() 42 | .subject((d: string) => { 43 | var str = groups[d].attr('transform'); 44 | var [a, b] = str.split(','); 45 | var x = +a.split('(')[1]; 46 | var y = +b.split(')')[0]; 47 | return { x, y }; 48 | }) 49 | .on('start', (d: string) => { 50 | if (groups[d].classed('invalid')) { 51 | groups[d].select('title').text(d); 52 | } 53 | groups[d].att.class('pin valid dirty'); 54 | }) 55 | .on('drag', (d: string) => { 56 | groups[d].att.translate(d3event as IPoint); 57 | events.onDrag && events.onDrag(d, $state.mapctl.location(d3event as IPoint)); 58 | }); 59 | 60 | 61 | export function reset(rows: number[][]) { 62 | clear(); 63 | root.selectAll('*').remove(); 64 | let dict = {} as StringMap; 65 | for (const group of rows) { 66 | for (const row of group) { 67 | const src = $state.config.source(row); 68 | const tar = $state.config.target(row); 69 | if (!(src in dict)) { 70 | dict[src] = $state.loc(src); 71 | } 72 | if (!(tar in dict)) { 73 | dict[tar] = $state.loc(tar); 74 | } 75 | } 76 | } 77 | const valids = [] as string[], invalids = [] as string[]; 78 | for (let key in dict) { 79 | dict[key] ? valids.push(key) : invalids.push(key); 80 | } 81 | _setup(valids, true); 82 | if (invalids.length > 0) { 83 | invalids.sort(); 84 | _setup(invalids, false); 85 | resize(); 86 | } 87 | } 88 | 89 | export function reformat() { 90 | var { located, unlocated } = $state.config.advance; 91 | root.selectAll('.pin.valid').sty.display(located ? null : 'none'); 92 | root.selectAll('.pin.invalid').sty.display(unlocated ? null : 'none'); 93 | } 94 | 95 | function _placer(length: number): Func { 96 | var { x, y, width, height } = $state.border; 97 | var loc = null as Func, gap = Math.min(20, width / length); 98 | return i => { 99 | return { x: i * gap - width / 2 + 10, y: height / 2 - 20 }; 100 | } 101 | } 102 | 103 | function _setup(addrs: string[], valid: boolean) { 104 | let selector = '.pin.' + (valid ? 'valid' : 'invalid'); 105 | let group = root.selectAll(selector).data(addrs).enter().append('g'); 106 | group.append('path').att.d('m 0 0 c -0.73840 -3.6248 -2.0403 -6.6412 -3.6172 ' + 107 | '-9.4370 c -1.1697 -2.0738 -2.5246 -3.9879 -3.7784 -5.9988 c -0.41851 ' + 108 | '-0.67131 -0.77970 -1.3805 -1.1818 -2.0772 c -0.80411 -1.3931 -1.4561 ' + 109 | '-3.0083 -1.4146 -5.1035 c 0.040476 -2.0471 0.63253 -3.6892 1.4863 -5.0318 ' + 110 | 'c 1.4042 -2.2083 3.7562 -4.0188 6.9121 -4.4946 c 2.5803 -0.38903 4.9995 ' + 111 | '0.26823 6.7151 1.2714 c 1.4019 0.81977 2.4875 1.9148 3.3128 3.2053 c 0.86133 ' + 112 | '1.3470 1.4545 2.9383 1.5042 5.0139 c 0.025467 1.0634 -0.14867 2.0482 -0.39398 ' + 113 | '2.8651 c -0.24826 0.82684 -0.64754 1.5180 -1.0028 2.2563 c -0.69345 1.4411 ' + 114 | '-1.5628 2.7616 -2.4353 4.0828 c -2.5988 3.9354 -5.0380 7.9488 -6.1063 13.448 Z'); 115 | group.append('circle').att.fill('black').att.cx(0).att.cy(-23.24).att.r(3.5); 116 | group.call(_drag as any); 117 | if (valid) { 118 | group.classed('pin valid', true) 119 | .att.translate(k => $state.mapctl.pixel($state.loc(k))) 120 | .append('title').text(k => k); 121 | } 122 | else { 123 | group.classed('pin invalid', true) 124 | .append('title').text(k => k+'(unlocated)'); 125 | } 126 | group.each(function (d: string) { groups[d] = selex(this); }); 127 | } -------------------------------------------------------------------------------- /code/src/lava/flowmap/popup.ts: -------------------------------------------------------------------------------- 1 | import { IListener } from "../bingmap"; 2 | import { Banner } from './banner'; 3 | import { Pie, all as pies, get as pie } from './pie'; 4 | import * as util from "./util"; 5 | import { StringMap, keys } from '../type'; 6 | import { ISelex, selex } from "../d3"; 7 | import { $state } from './app'; 8 | 9 | let root: ISelex; 10 | let banner: Banner; 11 | let selected: StringMap; 12 | 13 | export const events = { 14 | onChanged: null as (addrs: string[]) => void 15 | } 16 | 17 | export function init(d3: ISelex): IListener { 18 | root = d3; 19 | banner = new Banner(root) 20 | .content(p => marker(p)) 21 | .key(p => key(p)) 22 | .anchor(p => { 23 | const pnt = $state.pixel(p.addr, Microsoft.Maps.PixelReference.control); 24 | pnt.y += (p.type === 'out' ? -1 : 1) * p.radius(); 25 | return pnt; 26 | }); 27 | return { 28 | transform: () => banner.transform(), 29 | resize: () => banner.transform() 30 | }; 31 | } 32 | 33 | export function reset(marks: string[]) { 34 | selected = {}; 35 | for (let v of marks) { 36 | selected[v] = true; 37 | } 38 | } 39 | 40 | export function clear() { 41 | banner.clear(); 42 | //do not set selected to {}, can only be reset explictly 43 | //since we may need to display them after refreshing 44 | } 45 | 46 | export function repaint() { 47 | const label = $state.config.bubble.label; 48 | if (label === 'manual') { 49 | pies().sty.cursor('pointer'); 50 | } 51 | else { 52 | pies().sty.cursor('default'); 53 | } 54 | pies().on('click', p => { 55 | if (label === 'manual') { 56 | banner.flip(p, p.type === 'out' ? 'top' : 'bottom'); 57 | if (banner.contains(p)) { 58 | selected[key(p)] = true; 59 | } 60 | else { 61 | delete selected[key(p)]; 62 | } 63 | events.onChanged && events.onChanged(keys(selected)); 64 | } 65 | }); 66 | 67 | if (label === 'hide') { 68 | root.sty.display('none'); 69 | } 70 | else { 71 | root.sty.display(null); 72 | } 73 | 74 | banner.clear(); 75 | let oColor = util.rgb($state.config.bubble.labelColor.solid.color); 76 | let dColor = util.rgb($state.config.bubble.labelColor.solid.color); 77 | banner.background(p => p.type === 'out' ? oColor : dColor); 78 | banner.opacity(+$state.config.bubble.labelOpacity / 100); 79 | 80 | let prev = JSON.stringify(keys(selected || {}).sort()); 81 | if (label === 'none') { 82 | selected = {}; 83 | } 84 | else { 85 | let add = (p: Pie) => { 86 | banner.flip(p, p.type === 'out' ? 'top' : 'bottom'); 87 | selected[key(p)] = true; 88 | } 89 | if (label === 'all') { 90 | selected = {}; 91 | pies().each(p => add(p)); 92 | } 93 | else { 94 | for (let key of keys(selected).filter(k => pie(k))) { 95 | add(pie(key)); 96 | } 97 | } 98 | } 99 | if (events.onChanged) { 100 | let curr = JSON.stringify(keys(selected || {}).sort()); 101 | if (prev !== curr) { 102 | events.onChanged(keys(selected || {})); 103 | } 104 | } 105 | } 106 | 107 | function key(p: Pie) { 108 | return p.type + ' ' + p.addr; 109 | } 110 | 111 | function marker(pie: Pie): HTMLElement { 112 | let both = $state.config.bubble.in && $state.config.bubble.out; 113 | let title = $state.config.popup.origin, bullet = $state.config.popup.destination; 114 | if (pie.type !== 'out') { 115 | [title, bullet] = [bullet, title]; 116 | } 117 | let header = title(pie.rows[0]); 118 | if (!!both) { 119 | header = (pie.type === 'out' ? '(From) ' : '(To) ') + header; 120 | } 121 | let div = selex(document.createElement('div')).att.class('info').datum(pie); 122 | div.append('div').text(header).att.class('header'); 123 | if (!$state.config.popup.description) { 124 | return div.node(); 125 | } 126 | const top = util.top(pie.rows, $state.config); 127 | for (let row of top) { 128 | let prow = div.append('div').att.class('row').datum(row); 129 | prow.append('div').att.class('cell color').append('svg') 130 | .append('circle') 131 | .att.cx(5).att.cy(8).att.r(5) 132 | .att.fill(r => $state.color(r)) 133 | .att.stroke('white').att.stroke_width(1).att.stroke_opacity(0.8); 134 | prow.append('div').att.class('class title') 135 | .text(r => bullet(r)); 136 | prow.append('div').att.class('cell value') 137 | .text(r => $state.config.popup.description(r)); 138 | } 139 | if (pie.rows.length > top.length) { 140 | var prow = div.append('div').att.class('row'); 141 | prow.append('div').att.class('cell color').text('...') 142 | .sty.text_align('center'); 143 | prow.append('div').att.class('class title'); 144 | prow.append('div').att.class('cell value') 145 | .text('(' + (pie.rows.length - top.length) + ' more)'); 146 | } 147 | return div.node(); 148 | } -------------------------------------------------------------------------------- /code/src/lava/flowmap/pie.ts: -------------------------------------------------------------------------------- 1 | import { StringMap, Func, keys, dict, remap, groupBy } from '../type'; 2 | import { values } from 'd3'; 3 | import { sum } from 'd3-array'; 4 | import { IListener } from '../bingmap'; 5 | import { ISelex } from '../d3'; 6 | import { $state } from './app'; 7 | 8 | export class Pie { 9 | public readonly addr: string; 10 | public readonly d3: ISelex; 11 | public readonly type: 'out' | 'in'; 12 | public rows: number[]; 13 | public readonly total: number; 14 | 15 | constructor(root: ISelex, addr: string, rows: number[], type: 'out' | 'in') { 16 | this.addr = addr; 17 | this.type = type; 18 | this.d3 = root.datum(this).att.class('pie'); 19 | const weight = $state.config.weight.conv; 20 | this.total = sum(rows, r => weight(r)); 21 | this.rows = rows; 22 | } 23 | 24 | 25 | reshape() { 26 | this.d3.selectAll('*').remove(); 27 | const slice = $state.config.bubble.slice, weight = $state.config.weight.conv; 28 | if (!slice) { 29 | const color = $state.config.bubble.bubbleColor.solid.color; 30 | this.d3.append('circle').att.class('mask').att.fill(color) 31 | .att.stroke_opacity(0.8).att.stroke('white'); 32 | } 33 | else { 34 | const groups = groupBy(this.rows, $state.color); 35 | const colors = keys(groups); 36 | if (colors.length === 1) { 37 | this.d3.append('circle').att.class('mask').att.fill(colors[0]) 38 | .att.stroke_opacity(0.8).att.stroke('white'); 39 | } 40 | else { 41 | let slices = this.d3.append('g').att.class('slice'); 42 | this.d3.append('circle').att.class('mask') 43 | .att.fill('none').att.stroke('white') 44 | .att.stroke_width(1).att.stroke_opacity(0.8); 45 | let unit = 1 / this.total * 2 * Math.PI; 46 | let angles = colors.map(c => sum(groups[c], r => weight(r)) * unit); 47 | var start = 0 - Math.PI / 2; 48 | for (var i = 0; i < angles.length; i++) { 49 | var path = ['M 0 0'] as any[]; 50 | var sx = Math.cos(start); 51 | var sy = Math.sin(start); 52 | start += angles[i]; 53 | var ex = Math.cos(start); 54 | var ey = Math.sin(start); 55 | path.push('L', sx, sy); 56 | path.push('A', 1, 1, 0); 57 | path.push(angles[i] > Math.PI ? 1 : 0, 1, ex, ey, 'Z'); 58 | slices.append('path').att.d(path.join(' ')) 59 | .att.fill(colors[i]); 60 | } 61 | } 62 | } 63 | this.d3.att.translate($state.mapctl.pixel($state.loc(this.addr))); 64 | } 65 | 66 | private _radius = 1; 67 | radius(radius?: number): number { 68 | if (radius === undefined) { 69 | return this._radius; 70 | } 71 | radius = radius || this._radius; 72 | this.d3.selectAll('.slice').att.scale(radius); 73 | this.d3.selectAll('.mask').att.r(radius); 74 | return this._radius = radius; 75 | } 76 | } 77 | 78 | let root: ISelex; 79 | 80 | export const events = { 81 | onPieCreated: null as Func, void> 82 | } 83 | 84 | export function init(d3: ISelex): IListener { 85 | root = d3; 86 | return { transform: () => root.selectAll('.pie').att.translate(p => $state.pixel(p.addr)) }; 87 | } 88 | 89 | export function clear(): void { 90 | root.selectAll('*').remove(); 91 | _rows = []; 92 | origins = {}; 93 | destins = {}; 94 | } 95 | 96 | export function get(key: string): Pie { 97 | if (key.split(' ')[0] === 'in') { 98 | return destins[key.substr('in '.length)]; 99 | } 100 | else { 101 | return origins[key.substr('out '.length)]; 102 | } 103 | } 104 | 105 | export function all() { 106 | return root.selectAll('.pie'); 107 | } 108 | 109 | let _rows = [] as number[]; 110 | export function reset(data: number[]) { 111 | _rows = data; 112 | root.selectAll('*').remove(); 113 | origins = destins = {}; 114 | if ($state.config.bubble.out) { 115 | origins = build(groupBy(data, $state.config.bubble.out), 'out'); 116 | } 117 | if ($state.config.bubble.in) { 118 | destins = build(groupBy(data, $state.config.bubble.in), 'in'); 119 | } 120 | root.selectAll('.pie').sort((a, b) => b.total - a.total); 121 | events.onPieCreated && events.onPieCreated(root.selectAll('.pie')); 122 | resetRadius(); 123 | } 124 | 125 | let origins = {} as StringMap; 126 | let destins = {} as StringMap; 127 | 128 | function build(groups: StringMap, type: 'in' | 'out') { 129 | return remap(groups, (addr, rows) => { 130 | let pie = new Pie(root.append('g'), addr, rows, type); 131 | pie.reshape(); 132 | return pie; 133 | }); 134 | } 135 | 136 | function resetRadius() { 137 | const width = $state.width; 138 | const all = values(origins).concat(values(destins)); 139 | const factor = $state.config.bubble.scale / 10; 140 | const radius = (w: number) => Math.sqrt(width(w)) * factor + 2; 141 | for (let pie of all) { 142 | pie.radius(radius(pie.total)); 143 | } 144 | } 145 | 146 | export function hover(addrs: string[]) { 147 | if (addrs) { 148 | const marks = dict(addrs); 149 | root.selectAll('.mask').att.stroke(p => p.addr in marks ? '#333' : 'white'); 150 | } 151 | else { 152 | root.selectAll('.mask').att.stroke('white'); 153 | } 154 | } -------------------------------------------------------------------------------- /code/src/lava/flowmap/flow.ts: -------------------------------------------------------------------------------- 1 | import { Func } from '../type'; 2 | import { $state } from './app'; 3 | import { IShape, build } from './shape'; 4 | import { IPath } from './algo'; 5 | import { ISelex } from '../d3'; 6 | import { IListener, IBound, ILocation } from '../bingmap'; 7 | 8 | let root: ISelex; 9 | 10 | export const events = { 11 | hover: null as Func, 12 | pathInited: null as Func, void> 13 | } 14 | 15 | class VisualFlow { 16 | rows: number[]; 17 | 18 | public get bound() { 19 | return this._shape.bound; 20 | } 21 | 22 | public get source() { 23 | return this._shape.source; 24 | } 25 | 26 | reweight(weight: Func) { 27 | return this._shape.calc(weight); 28 | } 29 | 30 | private _tRoot: ISelex; 31 | private _sRoot: ISelex; 32 | private _shape: IShape; 33 | constructor(d3: ISelex, rows: number[]) { 34 | this._tRoot = d3.datum(this).att.class('vflow anchor'); 35 | this._sRoot = this._tRoot.append('g').att.class('scale'); 36 | this.rows = rows; 37 | this._relayout(); 38 | } 39 | 40 | remove() { 41 | this._tRoot.remove(); 42 | } 43 | 44 | public reformat(recolor: boolean, rewidth: boolean) { 45 | if (recolor) { 46 | const paths = this._sRoot.selectAll('.base'); 47 | if ($state.config.style === 'flow') { 48 | const color = $state.color(this.rows[0]); 49 | paths.att.stroke(color); 50 | } 51 | else { 52 | paths.att.stroke(p => $state.color(+p.id)); 53 | } 54 | } 55 | if (rewidth && this._shape) { 56 | this._shape.rewidth(); 57 | } 58 | } 59 | 60 | private _hoverTimer = null as number; 61 | private _hoverState = null as any; 62 | private _onover = (p: IPath) => { 63 | const rows = p.leafs as number[]; 64 | if (this._hoverTimer) { 65 | clearTimeout(this._hoverTimer); 66 | this._hoverTimer = null; 67 | } 68 | if (this._hoverState !== p.id && this._hoverState) { 69 | this._hoverState = null; 70 | events.hover && events.hover(null); 71 | } 72 | if (this._hoverState === null) { 73 | this._hoverTimer = window.setTimeout(() => { 74 | if (this._hoverState) { 75 | events.hover && events.hover(null); 76 | } 77 | events.hover && events.hover(rows); 78 | this._hoverState = p.id; 79 | this._hoverTimer = null; 80 | }, 300); 81 | } 82 | }; 83 | 84 | private _onout = () => { 85 | if (this._hoverTimer) { 86 | clearTimeout(this._hoverTimer); 87 | this._hoverTimer = null; 88 | } 89 | this._hoverTimer = window.setTimeout(() => { 90 | if (this._hoverState) { 91 | this._hoverState = null; 92 | events.hover && events.hover(null); 93 | } 94 | this._hoverTimer = null; 95 | }, 100); 96 | }; 97 | 98 | private _relayout() { 99 | this._shape = this._build(); 100 | if (!this._shape) { 101 | return; 102 | } 103 | let all = this._sRoot 104 | .selectAll('.flow') 105 | .on('mouseover', this._onover) 106 | .on('mouseout', this._onout); 107 | 108 | this._translate(); 109 | events.pathInited && events.pathInited(all); 110 | } 111 | 112 | transform(map: Microsoft.Maps.Map, pzoom: number) { 113 | if (this._shape) { 114 | this._shape.transform(map, pzoom); 115 | this._translate(); 116 | } 117 | } 118 | 119 | private _translate() { 120 | this._tRoot.att.translate($state.mapctl.pixel(this._shape.bound)); 121 | } 122 | 123 | private _build() { 124 | const source = $state.loc($state.config.source(this.rows[0])); 125 | const weights = this.rows.map(r => Math.max($state.config.weight.conv(r), 0)); 126 | const targets = this.rows.map(r => $state.loc($state.config.target(r))); 127 | return build($state.config.style, this._sRoot, source, targets, this.rows, weights); 128 | } 129 | } 130 | 131 | export function init(d3: ISelex): IListener { 132 | const rect = d3.append('rect'); 133 | const remask = () => rect.att.width($state.mapctl.map.getWidth()) 134 | .att.height($state.mapctl.map.getHeight()) 135 | .att.x(0 - $state.mapctl.map.getWidth() / 2) 136 | .att.y(0 - $state.mapctl.map.getHeight() / 2) 137 | .att.fill_opacity(0.01) 138 | .sty.pointer_events('none'); 139 | root = d3.append('g'); 140 | return { 141 | transform: (ctl, pzoom) => { 142 | flows.forEach(v => v.transform(ctl.map, pzoom)); 143 | remask(); 144 | }, 145 | resize: () => remask() 146 | } 147 | } 148 | 149 | export function add(rows: number[]) { 150 | flows.push(new VisualFlow(root.append('g'), rows)); 151 | } 152 | 153 | export function clear() { 154 | for (const v of flows) { 155 | v.remove(); 156 | } 157 | flows = []; 158 | } 159 | 160 | export function bounds(): IBound[] { 161 | return flows.map(f => f.bound); 162 | } 163 | 164 | export function sources(): ILocation[] { 165 | return flows.map(f => f.source); 166 | } 167 | 168 | let flows = [] as VisualFlow[]; 169 | 170 | export function reweight(weight: Func): number[] { 171 | let exts = flows.map(v => v.reweight(weight)); 172 | let min = Math.min(...exts.map(e => e[0])); 173 | let max = Math.max(...exts.map(e => e[1])); 174 | return [min, max]; 175 | } 176 | 177 | export function reformat(recolor: boolean, rewidth: boolean) { 178 | for (let f of flows) { 179 | f.reformat(recolor, rewidth); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /code/src/pbi/numberFormat.ts: -------------------------------------------------------------------------------- 1 | import { Func } from '../lava/type'; 2 | import { format } from 'd3-format'; 3 | import powerbi from 'powerbi-visuals-api'; 4 | 5 | export var capability = { 6 | "displayName": "Tooltip Format", 7 | "description": "Change the format of values shown in tooltips", 8 | "properties": { 9 | "notation": { 10 | "displayName": "Notation", 11 | "type": { 12 | "enumeration": [ 13 | { "displayName": "None", "value": "none" }, 14 | { "displayName": "Decimal (SI)", "value": "decSi" }, 15 | { "displayName": "Decimal", "value": "dec" }, 16 | { "displayName": "Exponent", "value": "exp" }, 17 | { "displayName": "Percentage", "value": "per" } 18 | ] 19 | } 20 | }, 21 | "unit": { 22 | "displayName": "Unit", 23 | "type": { 24 | "enumeration": [ 25 | { "displayName": "None", "value": "none" }, 26 | { "displayName": "Thousands", "value": "K" }, 27 | { "displayName": "Millions", "value": "M" }, 28 | { "displayName": "Billions", "value": "bn" }, 29 | { "displayName": "Trillions", "value": "T" } 30 | ] 31 | } 32 | }, 33 | "precFix": { 34 | "displayName": "Precision", 35 | "description": "The number of digits that follow the decimal point", 36 | "type": { "numeric": true } 37 | }, 38 | "precSig": { 39 | "displayName": "Precision", 40 | "description": "The number of significant digits", 41 | "type": { "numeric": true } 42 | }, 43 | "fix": { 44 | "displayName": "Fixed point", 45 | "type": { "bool": true } 46 | }, 47 | "comma": { 48 | "displayName": "Comma for thousands", 49 | "type": { "bool": true } 50 | }, 51 | "prefix": { 52 | "displayName": "Prefix", 53 | "type": { "text": true } 54 | }, 55 | "postfix": { 56 | "displayName": "Postfix", 57 | "type": { "text": true } 58 | } 59 | } 60 | }; 61 | 62 | export class Setting { 63 | label = "" as string; 64 | notation = 'none' as 'none' | 'decSi' | 'dec' | 'exp' | 'per'; 65 | unit = 'none' as 'none' | 'K' | 'M' | 'bn' | 'T'; 66 | precFix = 3; 67 | precSig = 4; 68 | fix = false; 69 | prefix = ""; 70 | postfix = ""; 71 | comma = true 72 | } 73 | 74 | export function build(setting: Setting): Func { 75 | var func = n => n + ""; 76 | if (setting.notation === 'none') { 77 | func = n => n + ""; 78 | } 79 | else if (setting.notation === 'exp') { 80 | var prec = Math.round(+setting.precSig); 81 | if (isNaN(prec) || prec < 1) { 82 | prec = this.default.precSig; 83 | } 84 | var conv = format('.' + prec + 'e'); 85 | func = n => conv(n); 86 | } 87 | else if (setting.notation === 'per') { 88 | var flag = setting.fix ? '%' : 'p'; 89 | if (setting.fix) { 90 | var prec = Math.round(+setting.precFix); 91 | if (isNaN(prec) || prec < 0) { 92 | prec = this.default.precFix; 93 | } 94 | } 95 | else { 96 | var prec = Math.round(+setting.precSig); 97 | if (isNaN(prec) || prec < 1) { 98 | prec = this.default.precSig; 99 | } 100 | } 101 | var p = setting.comma ? ',.' : '.'; 102 | var conv = format(p + prec + flag); 103 | func = n => conv(n); 104 | } 105 | else if (setting.notation === 'decSi') { 106 | var prec = Math.round(+setting.precSig); 107 | if (isNaN(prec) || prec < 1) { 108 | prec = this.default.precSig; 109 | } 110 | var conv = format('.' + prec + 's'); 111 | func = n => conv(n); 112 | } 113 | else if (setting.notation === 'dec') { 114 | var unit = setting.unit; 115 | var div = 1; 116 | if (unit === 'K') { 117 | div = 1000; 118 | } 119 | else if (unit === 'M') { 120 | div = 1000 * 1000; 121 | } 122 | else if (unit === 'bn') { 123 | div = 1000 * 1000 * 1000; 124 | } 125 | else if (unit === 'T') { 126 | div = 1000 * 1000 * 1000 * 1000; 127 | } 128 | var flag = setting.fix ? 'f' : 'r'; 129 | if (setting.fix) { 130 | var prec = Math.round(+setting.precFix); 131 | if (isNaN(prec) || prec < 0) { 132 | prec = this.default.precFix; 133 | } 134 | } 135 | else { 136 | var prec = Math.round(+setting.precSig); 137 | if (isNaN(prec) || prec < 1) { 138 | prec = this.default.precSig; 139 | } 140 | } 141 | var p = setting.comma ? ',.' : '.'; 142 | var conv = format(p + prec + flag); 143 | if (unit === 'none') { 144 | func = n => conv(n); 145 | } 146 | else { 147 | func = n => conv(n / div) + unit; 148 | } 149 | } 150 | 151 | var pre = setting.prefix.trim(); 152 | var pos = setting.postfix.trim(); 153 | return n => { 154 | if (n === null || n === undefined || isNaN(+n)) { 155 | return ''; 156 | } 157 | var str = func(+n); 158 | if (pre && pre.length > 0) { 159 | str = pre + ' ' + str; 160 | } 161 | if (pos && pos.length > 0) { 162 | str = str + ' ' + pos; 163 | } 164 | return str; 165 | } 166 | } 167 | 168 | export function visualObjects(format: Setting, oname: string): powerbi.VisualObjectInstance { 169 | var notation = format.notation; 170 | var props = {} as Setting; 171 | props.notation = format.notation; 172 | if (notation === 'exp' || notation === 'decSi') { 173 | props.precSig = format.precSig; 174 | } 175 | else if (notation === 'per') { 176 | props.fix = format.fix; 177 | if (format.fix) { 178 | props.precFix = format.precFix; 179 | } 180 | else { 181 | props.precSig = format.precSig; 182 | } 183 | props.comma = format.comma; 184 | } 185 | else if (notation === 'dec') { 186 | props.unit = format.unit; 187 | props.fix = format.fix; 188 | if (format.fix) { 189 | props.precFix = format.precFix; 190 | } 191 | else { 192 | props.precSig = format.precSig; 193 | } 194 | props.comma = format.comma; 195 | } 196 | props.prefix = format.prefix; 197 | props.postfix = format.postfix; 198 | // console.log(props); 199 | return { 200 | objectName: oname, 201 | properties: props as any, 202 | selector: null 203 | }; 204 | } -------------------------------------------------------------------------------- /code/src/lava/type.ts: -------------------------------------------------------------------------------- 1 | export interface ISize { 2 | width: number; 3 | height: number; 4 | } 5 | 6 | export interface IRect { 7 | x: number; 8 | y: number; 9 | width: number; 10 | height: number; 11 | } 12 | 13 | export interface IPoint { 14 | x: number; 15 | y: number; 16 | } 17 | 18 | export interface IResettable { 19 | reset(v?: any): this; 20 | } 21 | 22 | export type Action = () => void; 23 | 24 | export type StringMap = { [k: string]: T }; 25 | 26 | export type NumberMap = { [k: number]: T }; 27 | 28 | export type Func = (i: I) => O; 29 | 30 | export function buildLabels(values: any[]): string[] { 31 | const validValues = values.map(v => toDate(v) || v); 32 | const validDates = validValues.filter(v => v && v instanceof Date); 33 | const date2Label = dateString(validDates); 34 | return validValues.map(v => v instanceof Date ? date2Label(v) : v + ""); 35 | } 36 | 37 | export function toDate(value: Date | string | number): Date { 38 | if (value instanceof Date) { 39 | return value; 40 | } 41 | if (typeof value === 'string') { 42 | var ticks = Date.parse(value); 43 | return Number.isNaN(ticks) ? null : new Date(ticks); 44 | } 45 | return new Date(value); 46 | } 47 | 48 | function dateString(valids: Date[]): (v: Date) => string { 49 | if (valids.length === 0) { 50 | return null; 51 | } 52 | var years = valids.map(v => v.getFullYear()); 53 | var months = valids.map(v => v.getMonth()); 54 | var dates = valids.map(v => v.getDate()); 55 | var hours = valids.map(v => v.getHours()); 56 | var mins = valids.map(v => v.getMinutes()); 57 | var secs = valids.map(v => v.getSeconds()); 58 | var same = (vals: number[]) => { 59 | var first = vals[0]; 60 | return vals.every(v => v === first); 61 | } 62 | var zero = (vals: number[]) => vals.every(v => v === 0); 63 | 64 | var str = (v: number) => v < 10 ? '0' + v : v; 65 | var hm = (v: Date) => str(v.getHours()) + ':' + str(v.getMinutes()); 66 | var hms = (v: Date) => hm(v) + ':' + str(v.getSeconds()); 67 | var dm = (v: Date) => v.getDate() + '/' + (v.getMonth() + 1); 68 | var dmy = (v: Date) => dm(v) + '/' + v.getFullYear(); 69 | 70 | if (same(years)) { 71 | if (!zero(secs)) { 72 | return v => dm(v) + ' ' + hms(v); 73 | } 74 | if (!zero(mins) || !zero(hours)) { 75 | return v => dm(v) + ' ' + hm(v); 76 | } 77 | return v => dmy(v); 78 | } 79 | else { 80 | if (!zero(secs)) { 81 | return v => dmy(v) + ' ' + hms(v); 82 | } 83 | if (!zero(mins) || !zero(hours)) { 84 | return v => dmy(v) + ' ' + hm(v); 85 | } 86 | if (!same(dates)) { 87 | return v => v.getFullYear() + "-" + (v.getMonth() + 1) + "-" + v.getDate(); 88 | } 89 | if (!same(months)) { 90 | return v => v.getFullYear() + "-" + (v.getMonth() + 1); 91 | } 92 | return v => v.getFullYear() + ""; 93 | } 94 | } 95 | 96 | export function first(data: T[], p: Func, dft?: T): T { 97 | if (!data || data.length === 0) { 98 | return dft; 99 | } 100 | for (let v of data) { 101 | if (p(v)) { 102 | return v; 103 | } 104 | } 105 | return dft; 106 | } 107 | 108 | export function keys(...data: StringMap[]): string[] { 109 | if (data.length === 0) { 110 | return []; 111 | } 112 | if (data.length === 1) { 113 | return Object.keys(data[0] || {}); 114 | } 115 | let result = {} as StringMap; 116 | for (let d of data) { 117 | for (let k in d) { 118 | result[k] = true; 119 | } 120 | } 121 | return Object.keys(result); 122 | } 123 | 124 | export function clamp(v: number, min: number, max: number): number { 125 | if (v < min) 126 | return min; 127 | if (v > max) 128 | return max; 129 | return v; 130 | } 131 | 132 | export function dict(values: string[] | number[] | (string | number)[]): StringMap; 133 | export function dict(values: K[], key: Func): StringMap; 134 | export function dict(values: K[], key: Func, val: Func): StringMap; 135 | export function dict(values: K[], key?: Func, val?: Func): StringMap { 136 | var dict = {} as StringMap; 137 | if (key) { 138 | for (var v of values) { 139 | dict[key(v)] = val ? val(v) : (v as any); 140 | } 141 | } 142 | else { 143 | for (var v of values) { 144 | dict[v as any] = v as any; 145 | } 146 | } 147 | return dict; 148 | } 149 | 150 | export function sequence(start: number, count: number): number[] { 151 | var result = [] as number[], end = start + count; 152 | for (; start < end; start++) { 153 | result.push(start); 154 | } 155 | return result; 156 | } 157 | 158 | export function sort(data: T[], selector: (datum: T, idx: number) => number): T[] { 159 | if (!data) { 160 | return null; 161 | } 162 | let idxs = sequence(0, data.length); 163 | idxs.sort((i1, i2) => selector(data[i1], i1) - selector(data[i2], i2)); 164 | let result = [] as T[]; 165 | for (const i of idxs) { 166 | result.push(data[i]); 167 | } 168 | return result; 169 | } 170 | 171 | 172 | export function groupBy(values: readonly V[], group: Func): StringMap { 173 | let result = {} as StringMap; 174 | for (let r of values) { 175 | let key = group(r); 176 | if (key in result) { 177 | result[key].push(r); 178 | } 179 | else { 180 | result[key] = [r]; 181 | } 182 | } 183 | return result; 184 | } 185 | 186 | export function remap(input: StringMap, map: (key: string, val: I) => O): StringMap { 187 | let result = {} as StringMap; 188 | for (let key in input) { 189 | result[key] = map(key, input[key]); 190 | } 191 | return result; 192 | } 193 | 194 | export function pick(data: I[], conv: Func, where?: Func): O[] { 195 | var ret = [] as O[]; 196 | if (where) { 197 | return data.filter(where).map(conv); 198 | } 199 | for (let v of data) { 200 | let o = conv(v); 201 | if (o !== undefined && o !== null) { 202 | ret.push(o); 203 | } 204 | } 205 | return ret; 206 | } 207 | 208 | export function values(a: StringMap): T[] { 209 | return Object.keys(a).map(k => a[k]); 210 | } 211 | 212 | export function partial(a: T, keys: (keyof T)[]): Partial { 213 | var ret = {} as T; 214 | for (var key of keys) { 215 | ret[key] = a[key]; 216 | } 217 | return ret; 218 | } 219 | 220 | 221 | export function check(assert: any, msg: string) { 222 | if (!assert) { 223 | debugger; 224 | console.log(msg); 225 | } 226 | } 227 | 228 | export function override(source: any, target: any) { 229 | if (!source) { 230 | return target; 231 | } 232 | for (let p in target) { 233 | if (target[p].constructor == Object) { 234 | if (source[p]) 235 | target[p] = override(source[p], target[p]); 236 | } 237 | else { 238 | if (p in source) { 239 | target[p] = source[p]; 240 | } 241 | } 242 | } 243 | return target; 244 | } 245 | 246 | export function copy(source: S): S; 247 | export function copy(source: Partial, target: T): T; 248 | export function copy(source: S, target: T): T; 249 | export function copy(source: S, target: T, keys: (keyof (S))[]): T | Partial; 250 | export function copy(source: Partial, target?: T, keys?: (keyof T)[]): T { 251 | target = target || {} as any; 252 | if (!source) { 253 | return target; 254 | } 255 | if (keys) { 256 | for (const key of keys) { 257 | if (key in source) { 258 | target[key] = source[key]; 259 | } 260 | } 261 | } 262 | else { 263 | for (const key in source) { 264 | target[key] = source[key]; 265 | } 266 | } 267 | return target; 268 | } -------------------------------------------------------------------------------- /docs/lib/css/slider/themes/default.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Ideal Image Slider Default Theme 3 | * Version: 1.2.0 4 | */ 5 | 6 | .ideal-image-slider { 7 | background-color: #fff; 8 | background-image: url("data:image/gif;base64,R0lGODlhIAAgAPMAAP///wAAAMbGxoSEhLa2tpqamjY2NlZWVtjY2OTk5Ly8vB4eHgQEBAAAAAAAAAAAACH+GkNyZWF0ZWQgd2l0aCBhamF4bG9hZC5pbmZvACH5BAAKAAAAIf8LTkVUU0NBUEUyLjADAQAAACwAAAAAIAAgAAAE5xDISWlhperN52JLhSSdRgwVo1ICQZRUsiwHpTJT4iowNS8vyW2icCF6k8HMMBkCEDskxTBDAZwuAkkqIfxIQyhBQBFvAQSDITM5VDW6XNE4KagNh6Bgwe60smQUB3d4Rz1ZBApnFASDd0hihh12BkE9kjAJVlycXIg7CQIFA6SlnJ87paqbSKiKoqusnbMdmDC2tXQlkUhziYtyWTxIfy6BE8WJt5YJvpJivxNaGmLHT0VnOgSYf0dZXS7APdpB309RnHOG5gDqXGLDaC457D1zZ/V/nmOM82XiHRLYKhKP1oZmADdEAAAh+QQACgABACwAAAAAIAAgAAAE6hDISWlZpOrNp1lGNRSdRpDUolIGw5RUYhhHukqFu8DsrEyqnWThGvAmhVlteBvojpTDDBUEIFwMFBRAmBkSgOrBFZogCASwBDEY/CZSg7GSE0gSCjQBMVG023xWBhklAnoEdhQEfyNqMIcKjhRsjEdnezB+A4k8gTwJhFuiW4dokXiloUepBAp5qaKpp6+Ho7aWW54wl7obvEe0kRuoplCGepwSx2jJvqHEmGt6whJpGpfJCHmOoNHKaHx61WiSR92E4lbFoq+B6QDtuetcaBPnW6+O7wDHpIiK9SaVK5GgV543tzjgGcghAgAh+QQACgACACwAAAAAIAAgAAAE7hDISSkxpOrN5zFHNWRdhSiVoVLHspRUMoyUakyEe8PTPCATW9A14E0UvuAKMNAZKYUZCiBMuBakSQKG8G2FzUWox2AUtAQFcBKlVQoLgQReZhQlCIJesQXI5B0CBnUMOxMCenoCfTCEWBsJColTMANldx15BGs8B5wlCZ9Po6OJkwmRpnqkqnuSrayqfKmqpLajoiW5HJq7FL1Gr2mMMcKUMIiJgIemy7xZtJsTmsM4xHiKv5KMCXqfyUCJEonXPN2rAOIAmsfB3uPoAK++G+w48edZPK+M6hLJpQg484enXIdQFSS1u6UhksENEQAAIfkEAAoAAwAsAAAAACAAIAAABOcQyEmpGKLqzWcZRVUQnZYg1aBSh2GUVEIQ2aQOE+G+cD4ntpWkZQj1JIiZIogDFFyHI0UxQwFugMSOFIPJftfVAEoZLBbcLEFhlQiqGp1Vd140AUklUN3eCA51C1EWMzMCezCBBmkxVIVHBWd3HHl9JQOIJSdSnJ0TDKChCwUJjoWMPaGqDKannasMo6WnM562R5YluZRwur0wpgqZE7NKUm+FNRPIhjBJxKZteWuIBMN4zRMIVIhffcgojwCF117i4nlLnY5ztRLsnOk+aV+oJY7V7m76PdkS4trKcdg0Zc0tTcKkRAAAIfkEAAoABAAsAAAAACAAIAAABO4QyEkpKqjqzScpRaVkXZWQEximw1BSCUEIlDohrft6cpKCk5xid5MNJTaAIkekKGQkWyKHkvhKsR7ARmitkAYDYRIbUQRQjWBwJRzChi9CRlBcY1UN4g0/VNB0AlcvcAYHRyZPdEQFYV8ccwR5HWxEJ02YmRMLnJ1xCYp0Y5idpQuhopmmC2KgojKasUQDk5BNAwwMOh2RtRq5uQuPZKGIJQIGwAwGf6I0JXMpC8C7kXWDBINFMxS4DKMAWVWAGYsAdNqW5uaRxkSKJOZKaU3tPOBZ4DuK2LATgJhkPJMgTwKCdFjyPHEnKxFCDhEAACH5BAAKAAUALAAAAAAgACAAAATzEMhJaVKp6s2nIkolIJ2WkBShpkVRWqqQrhLSEu9MZJKK9y1ZrqYK9WiClmvoUaF8gIQSNeF1Er4MNFn4SRSDARWroAIETg1iVwuHjYB1kYc1mwruwXKC9gmsJXliGxc+XiUCby9ydh1sOSdMkpMTBpaXBzsfhoc5l58Gm5yToAaZhaOUqjkDgCWNHAULCwOLaTmzswadEqggQwgHuQsHIoZCHQMMQgQGubVEcxOPFAcMDAYUA85eWARmfSRQCdcMe0zeP1AAygwLlJtPNAAL19DARdPzBOWSm1brJBi45soRAWQAAkrQIykShQ9wVhHCwCQCACH5BAAKAAYALAAAAAAgACAAAATrEMhJaVKp6s2nIkqFZF2VIBWhUsJaTokqUCoBq+E71SRQeyqUToLA7VxF0JDyIQh/MVVPMt1ECZlfcjZJ9mIKoaTl1MRIl5o4CUKXOwmyrCInCKqcWtvadL2SYhyASyNDJ0uIiRMDjI0Fd30/iI2UA5GSS5UDj2l6NoqgOgN4gksEBgYFf0FDqKgHnyZ9OX8HrgYHdHpcHQULXAS2qKpENRg7eAMLC7kTBaixUYFkKAzWAAnLC7FLVxLWDBLKCwaKTULgEwbLA4hJtOkSBNqITT3xEgfLpBtzE/jiuL04RGEBgwWhShRgQExHBAAh+QQACgAHACwAAAAAIAAgAAAE7xDISWlSqerNpyJKhWRdlSAVoVLCWk6JKlAqAavhO9UkUHsqlE6CwO1cRdCQ8iEIfzFVTzLdRAmZX3I2SfZiCqGk5dTESJeaOAlClzsJsqwiJwiqnFrb2nS9kmIcgEsjQydLiIlHehhpejaIjzh9eomSjZR+ipslWIRLAgMDOR2DOqKogTB9pCUJBagDBXR6XB0EBkIIsaRsGGMMAxoDBgYHTKJiUYEGDAzHC9EACcUGkIgFzgwZ0QsSBcXHiQvOwgDdEwfFs0sDzt4S6BK4xYjkDOzn0unFeBzOBijIm1Dgmg5YFQwsCMjp1oJ8LyIAACH5BAAKAAgALAAAAAAgACAAAATwEMhJaVKp6s2nIkqFZF2VIBWhUsJaTokqUCoBq+E71SRQeyqUToLA7VxF0JDyIQh/MVVPMt1ECZlfcjZJ9mIKoaTl1MRIl5o4CUKXOwmyrCInCKqcWtvadL2SYhyASyNDJ0uIiUd6GGl6NoiPOH16iZKNlH6KmyWFOggHhEEvAwwMA0N9GBsEC6amhnVcEwavDAazGwIDaH1ipaYLBUTCGgQDA8NdHz0FpqgTBwsLqAbWAAnIA4FWKdMLGdYGEgraigbT0OITBcg5QwPT4xLrROZL6AuQAPUS7bxLpoWidY0JtxLHKhwwMJBTHgPKdEQAACH5BAAKAAkALAAAAAAgACAAAATrEMhJaVKp6s2nIkqFZF2VIBWhUsJaTokqUCoBq+E71SRQeyqUToLA7VxF0JDyIQh/MVVPMt1ECZlfcjZJ9mIKoaTl1MRIl5o4CUKXOwmyrCInCKqcWtvadL2SYhyASyNDJ0uIiUd6GAULDJCRiXo1CpGXDJOUjY+Yip9DhToJA4RBLwMLCwVDfRgbBAaqqoZ1XBMHswsHtxtFaH1iqaoGNgAIxRpbFAgfPQSqpbgGBqUD1wBXeCYp1AYZ19JJOYgH1KwA4UBvQwXUBxPqVD9L3sbp2BNk2xvvFPJd+MFCN6HAAIKgNggY0KtEBAAh+QQACgAKACwAAAAAIAAgAAAE6BDISWlSqerNpyJKhWRdlSAVoVLCWk6JKlAqAavhO9UkUHsqlE6CwO1cRdCQ8iEIfzFVTzLdRAmZX3I2SfYIDMaAFdTESJeaEDAIMxYFqrOUaNW4E4ObYcCXaiBVEgULe0NJaxxtYksjh2NLkZISgDgJhHthkpU4mW6blRiYmZOlh4JWkDqILwUGBnE6TYEbCgevr0N1gH4At7gHiRpFaLNrrq8HNgAJA70AWxQIH1+vsYMDAzZQPC9VCNkDWUhGkuE5PxJNwiUK4UfLzOlD4WvzAHaoG9nxPi5d+jYUqfAhhykOFwJWiAAAIfkEAAoACwAsAAAAACAAIAAABPAQyElpUqnqzaciSoVkXVUMFaFSwlpOCcMYlErAavhOMnNLNo8KsZsMZItJEIDIFSkLGQoQTNhIsFehRww2CQLKF0tYGKYSg+ygsZIuNqJksKgbfgIGepNo2cIUB3V1B3IvNiBYNQaDSTtfhhx0CwVPI0UJe0+bm4g5VgcGoqOcnjmjqDSdnhgEoamcsZuXO1aWQy8KAwOAuTYYGwi7w5h+Kr0SJ8MFihpNbx+4Erq7BYBuzsdiH1jCAzoSfl0rVirNbRXlBBlLX+BP0XJLAPGzTkAuAOqb0WT5AH7OcdCm5B8TgRwSRKIHQtaLCwg1RAAAOwAAAAAAAAAAAA=="); 9 | background-repeat: no-repeat; 10 | background-position: 50% 50%; 11 | background-size: 32px 32px; 12 | } 13 | 14 | /* Navigation */ 15 | .iis-previous-nav, 16 | .iis-next-nav { 17 | position: absolute; 18 | top: 50%; 19 | z-index: 20; 20 | display: block; 21 | width: 60px; 22 | height: 60px; 23 | text-indent: -9999px; 24 | background-repeat: no-repeat; 25 | background-color: rgba(0,0,0,0.5); 26 | border-radius: 50px; 27 | background-size: 48px 48px; 28 | cursor: pointer; 29 | opacity: 0; 30 | -webkit-transform: translateY(-50%); 31 | -ms-transform: translateY(-50%); 32 | transform: translateY(-50%); 33 | -webkit-transition: 0.3s ease-out; 34 | -moz-transition: 0.3s ease-out; 35 | -o-transition: 0.3s ease-out; 36 | transition: 0.3s ease-out; 37 | } 38 | .iis-previous-nav { 39 | left: 5%; 40 | background-position: 35% 50%; 41 | background-image: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIj8+Cjxzdmcgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6c3ZnPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiA8Zz4KICA8dGl0bGU+TGF5ZXIgMTwvdGl0bGU+CiAgPHBvbHlnb24gZmlsbD0iI2ZmZmZmZiIgaWQ9InN2Z18xIiBwb2ludHM9IjM1MiwxMTUuNCAzMzEuMyw5NiAxNjAsMjU2IDMzMS4zLDQxNiAzNTIsMzk2LjcgMjAxLjUsMjU2ICIvPgogPC9nPgo8L3N2Zz4="); 42 | } 43 | .iis-next-nav { 44 | right: 5%; 45 | background-position: 65% 50%; 46 | background-image: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIj8+Cjxzdmcgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6c3ZnPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiA8Zz4KICA8dGl0bGU+TGF5ZXIgMTwvdGl0bGU+CiAgPHBvbHlnb24gZmlsbD0iI2ZmZmZmZiIgaWQ9InN2Z18xIiBwb2ludHM9IjE2MCwxMTUuNCAxODAuNyw5NiAzNTIsMjU2IDE4MC43LDQxNiAxNjAsMzk2LjcgMzEwLjUsMjU2ICIvPgogPC9nPgo8L3N2Zz4="); 47 | } 48 | .ideal-image-slider:hover .iis-previous-nav, 49 | .ideal-image-slider:hover .iis-next-nav { opacity: 0.5; } 50 | .ideal-image-slider:hover .iis-previous-nav:hover, 51 | .ideal-image-slider:hover .iis-next-nav:hover { opacity: 1.0; } 52 | 53 | /* Bullet Navigation */ 54 | .iis-bullet-nav { 55 | position: absolute; 56 | bottom: 5%; 57 | right: 5%; 58 | z-index: 15; 59 | width: 90%; 60 | text-align: right; 61 | opacity: 0.4; 62 | -webkit-transition: 0.3s ease-out; 63 | -moz-transition: 0.3s ease-out; 64 | -o-transition: 0.3s ease-out; 65 | transition: 0.3s ease-out; 66 | } 67 | .iis-has-captions .iis-bullet-nav { max-width: 42%; } 68 | .iis-bullet-nav a { 69 | display: inline-block; 70 | width: 10px; 71 | height: 10px; 72 | background: transparent; 73 | text-indent: 9999px; 74 | margin: 0 5px; 75 | border: 3px solid rgba(0,0,0,0.5); 76 | border-radius: 10px; 77 | cursor: pointer; 78 | -webkit-transition: 0.3s ease-out; 79 | -moz-transition: 0.3s ease-out; 80 | -o-transition: 0.3s ease-out; 81 | transition: 0.3s ease-out; 82 | } 83 | .iis-bullet-nav a.iis-bullet-active, 84 | .iis-bullet-nav a:hover { background: #fff; } 85 | .ideal-image-slider:hover .iis-bullet-nav { opacity: 0.7; } 86 | .ideal-image-slider:hover .iis-bullet-nav:hover { opacity: 1.0; } 87 | 88 | /* Captions */ 89 | .iis-has-captions .iis-slide { text-indent: 0; } 90 | .iis-caption { 91 | position: absolute; 92 | left: 5%; 93 | bottom: 5%; 94 | max-width: 90%; 95 | z-index: 10; 96 | background: #000; 97 | background: rgba(0,0,0,0.5); 98 | padding: 5px 15px; 99 | border-radius: 10px; 100 | font: 14px/1.6em "Helvetica Neue", Helvetica, Arial, sans-serif; 101 | color: #fff; 102 | -webkit-box-sizing: border-box; 103 | -moz-box-sizing: border-box; 104 | box-sizing: border-box; 105 | } 106 | .iis-has-bullet-nav .iis-caption { max-width: 42%; } 107 | .iis-caption .iis-caption-title { font-weight: bold; } 108 | .iis-caption .iis-caption-content { 109 | font-size: 13px; 110 | line-height: 1.6em; 111 | color: #eee; 112 | } 113 | .iis-caption .iis-caption-content a, 114 | .iis-caption .iis-caption-content a:visited { 115 | color: #eee; 116 | text-decoration: underline; 117 | border: 0; 118 | } 119 | .iis-caption .iis-caption-content a:hover, 120 | .iis-caption .iis-caption-content a:active { 121 | color: #fff; 122 | } 123 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: PowerBI - Flowmap Custom Visual 4 | image_sliders: 5 | - slider1 6 | --- 7 | 8 | ## Background 9 | 10 | Flow maps are a special type of network visualization for object movements, such as the number of people in a migration. A typical flow map, which contains one source and multiple targets, is visualized as a flow-style tree overlaid on top of a map. 11 | 12 | {% include slider.html selector="slider1" %} 13 | 14 | The line thicknesses are scaled to represent the values between the source (the root) and the targets (the leaves). By merging edges together, Flow maps can reduce visual clutter and enhance directional trends. 15 | 16 | ## Where to Get It 17 | 18 | You can get it from the [Office Store](https://store.office.com/en-us/app.aspx?assetid=WA104380901&sourcecorrid=ae7baae3-68e1-488c-b34c-ac1e9f8cc8d7&searchapppos=62&appredirect=false&omkt=en-US&ui=en-US&rs=en-US&ad=US) or the [_dist_](https://github.com/weiweicui/PowerBI-Flowmap/tree/master/dist) folder in this [_repo_](https://github.com/weiweicui/PowerBI-Flowmap/). 19 | 20 | * Update 1.1.3: 21 | * Add **Advanced - Flow style**: Can change the visualization style between `curve`, `great circle`, and `straight line`. 22 | * Add an optional field **Tooltip**: Now it is customizable. By default, the value field is used. 23 | * Add **Tooltip Format**: Can customize the format of values (if they are numbers) displayed in tooltips. 24 | * Remove the flow limit: Now can set any number in **Advanced - Flow limit**, which is previously capped by 10. However, the predefined good categorical colors may run out. 25 | * Update 1.1.4: 26 | * Add **Origin/Destination name** fields: By default, values in **Origin** and **Destination** fields are used in tooltips. However, they may be too long or too ugly if you also want to use them for the geocoding purpose. Now you can set these two fields and show friendly names in tooltips. 27 | * Add **Advanced - Language**: Can change the language used in the background map. 28 | * Add **Advanced - Cache**: Store the geocoding results, so they can be reused when you open the report next time. 29 | * Update 1.2.4: 30 | * Add **Map control** format: The map-related settings (in **Advanced**) are moved here, and add some more: 31 | * **Auto fit**: Zoom/pan the map to fit everything in the viewport when the selection is changed. 32 | * **Type**: Change the map style between `Road` and `Aerial`. 33 | * Add **Map element** format: In case you find some map elements, such as roads and labels, distracting, you can turn them off here. 34 | * Change the color setting to be consistent with other visuals. By defualt, all flows use the same color. You need to change them manually in the **Flow color**. In addition, only the colors with non-empty labels are displayed in the legend bar. 35 | * Update 1.2.6 (store version): 36 | * Add **Label** format: Now the content displayed in the bubble labels can be different from the hovering tooltips. If nothing in the field, the names are displayed. 37 | * Adjust **Width** and **Color** fields: Previously, **Width** field only takes numeric values and **Color** field only takes distrete values. Now they both can take either continuous values or discrete values. However, due to the algorithm limit, `Flow` style does not work with continuous values in the **Color** field. 38 | * Separate the **Visual style** format (from **Advance - Flow style**): Now it is more prominent. And now only `Flow` style has the number constraint. In particular, I add a `Auto` style. So the visual determines the exact style based on the following rules (of course, you can manually pick one by yourself): 39 | * Choose `Flow` if the the number of total flows is less than 5, otherwise, 40 | * Choose `Great circle` if the total row number is less than 500, otherwise, 41 | * Choose `Straight`. 42 | * Adjust **Bubble** format: Now you can: 43 | * Choose to show bubbles for origins or destinations. 44 | * Just show overall sizes instead of slices. 45 | * Set bubble to the same (and small) size if **Bubble - Scale** is set to `0%`. 46 | * Change label background colors for origins or destinations. 47 | * Add a couple of more default map styles to **Map control - Type**. 48 | * Update 1.3.0: 49 | * Add the **Legend - Color/Width - (default)** options. By default, the color and width legends are empty and you need to type the labels manually. Now when you turn on the **(default)** swithces, the legend will directly use the default labels if you have not specify them explicitly. 50 | * Update 1.3.1: 51 | * Refine the **Bubble - Scale**. Use sigmoid function to scale bubble sizes when they are too large or too small. 52 | * Update 1.3.2: 53 | * Add a **Color - (Autofill)** switch. It only shows when color field is categorical. It will automatically give distinct colors to unspecified categories. 54 | * Update 1.3.3: 55 | * Fix a bug that map related formats cannot remember settings. 56 | * Update 1.4.*: 57 | * Update to custom visual api v2.6. This should fix some issues due to api issues. But since the implementation is changed a lot, bugs are expected. Please comment below if found. 58 | * Remove the filter operation from this visual (i.e., now cannot click on legend or flows to highlight.) 59 | * Show bubbles for self-linked flow. It still cannot show lines if the origin and destination are the same, but we can show a circle on the map if the bubble visualization is turned on. 60 | * Remove **Bubble - For - Both** option. 61 | * Add a **Map control - Type - Hidden** option to hide the underlying map completely. 62 | 63 | 64 | 65 | ## How to Use (Latest Version) 66 | * Required fields: 67 | * **Origin** and **Destination**: These two fields are used to construct relationships. The content there may be treated as addresses and used to query geo-locations (through [Bing Maps REST Services](https://msdn.microsoft.com/en-us/library/ff701713.aspx)) if latitude/longitude are not specified. 68 | 69 | * Optional fields: 70 | * **Width**: This field is used to compute flow widths. Negative values will be ignored. The default value is 1. The field can be numerical or categorical: 71 | * When numerical, you can specify a min and max width to map them on the map. 72 | * When categorical, such as texts, you can specify the width for each category, and a default value is used is not specified. 73 | * **Color**: This field is used to compute flow colors. The field can be numerical or categorical: 74 | * When numerical, you can set a min and max color to map them on the map, and values in between are interpolated. 75 | * When categorical, such as texts, you can sepcify a color for each category. A default color is used when not specified. 76 | >Please note that, due to implementation limit, `Flow` style only works with categorical color values. 77 | * **Origin/Destination latitude/longitude**: These fields specify the geo-locations of sources and targets. 78 | > Please note that only decimal numbers are accepted: latitudes range from -90 to 90, while longitudes range from -180 to 180. 79 | * **Origin/Destination name**: These fields are used to represent origins and destinations when displayed in tooltips or labels. Sometimes, the values in the **Origin** and **Destination** fields are needed for geo-coding and cannot be user-friendly. So these two fields can help put friendly names in the report. 80 | * **Tooltip**: You can choose what to show when hovering over a line or a bubble. By default, the names are displayed. 81 | * **Label**: We can show labels for bubbles. This field will decide the content in the labels. By default the names are used. You can turn on this feature in the **Bubble - Label** panel. 82 | 83 | * Major settings: 84 | * **Visual style**: You can change between `Flow`, `Great circle`, and `Straight line`. 85 | In particular, I add a `Auto` style. So the visual determines the exact style based on the following rules (of course, you can manually pick one by yourself): 86 | * Choose `Flow` if the the number of total flows is less than 5, otherwise, 87 | * Choose `Great circle` if the total row number is less than 500, otherwise, 88 | * Choose `Straight`. 89 | >Please note that only the `Flow` style has a constraint to limit the number of flows displayed in the view. In addition, you can choose to bundle flows based on origins or destinations. 90 | * **Color** and **Width**: These two panels allow you to adjust line attributes. Their contents may be adjusted in different situations. 91 | * **Bubble - Label**: You need to set it to make the **Label** field works. 92 | * **Detail format**: This panel controls how numerical values are formatted in tooltips or labels. 93 | * **Map control - Auto fit**: When it is on, whe visual will try to fit everything in one view when a data change is detected. 94 | * **Map element**: There are some high-level controls about what elements can be displayed in the visual. 95 | 96 | 97 | 98 | 99 | * Need more help? Please leave a comment below. 100 | -------------------------------------------------------------------------------- /code/src/lava/flowmap/banner.ts: -------------------------------------------------------------------------------- 1 | import { IRect, IPoint, Func, StringMap } from "../type"; 2 | import { ISelex, selex } from '../d3'; 3 | //hint 4 | // this._banner = new Banner(d3.select('#lava_banner')) 5 | // .content(p => p.info()) 6 | // .key(p => p.addr) 7 | // .origin(p => { 8 | // var bubble = p.d3.node(); 9 | // // while (!bubble.getScreenCTM) { 10 | // // bubble = bubble.parentNode as SVGSVGElement; 11 | // // } 12 | // var zero = svg.createSVGPoint(); 13 | // var pnt = $con.layer.pixel(p.addr); 14 | // zero.x = pnt.x; 15 | // zero.y = pnt.y; 16 | // pnt = zero.matrixTransform(bubble.getScreenCTM()); 17 | // pnt.y -= p.radius($fmt.bubble.values.size, $con.legend.scale()); 18 | // return pnt; 19 | // }) 20 | // .border(() => $con.border()); 21 | 22 | //css 23 | // .tip { 24 | // pointer-events: none; 25 | // position: absolute; 26 | // display: flex; 27 | // .arrow { 28 | // width:0px; 29 | // height:0px; 30 | // border-style: solid; 31 | // border-width: 7px; 32 | // border-color: transparent; 33 | // } 34 | // .content-wrap { 35 | // padding: 7px; 36 | // font-size: 11px; 37 | // } 38 | // } 39 | // .tip.top { 40 | // flex-direction: column-reverse; 41 | // } 42 | // .tip.bottom { 43 | // flex-direction: column; 44 | // } 45 | // .tip.left { 46 | // flex-direction: row-reverse; 47 | // } 48 | // .tip.right { 49 | // flex-direction: row; 50 | // } 51 | export class Banner { 52 | private _root = null as ISelex; 53 | private _origin = null as Func; 54 | private _key = null as Func; 55 | private _content = null as Func; 56 | private _data = {} as StringMap<{ tip: ISelex, value: any, rect: IRect, pos: string }>; 57 | constructor(root: ISelex, color = [242, 200, 17]) { 58 | this._root = root; 59 | this._rgb = color.join(', '); 60 | } 61 | 62 | public _background = null as Func; 63 | public background(conv: Func) { 64 | this._background = conv; 65 | } 66 | 67 | private _rgb: string; 68 | public clear(): this { 69 | this._root.selectAll('.tip').remove(); 70 | this._data = {}; 71 | return this; 72 | } 73 | 74 | private _border = null as () => IRect; 75 | border(v: () => IRect): this { 76 | this._border = v; 77 | return this; 78 | } 79 | 80 | public display(v: boolean) { 81 | this._root.style('display', v ? null : 'none'); 82 | } 83 | 84 | public anchor(conv: Func): this { 85 | this._origin = conv; 86 | return this; 87 | } 88 | 89 | public key(conv: Func): this { 90 | this._key = conv; 91 | return this; 92 | } 93 | 94 | public add(v: T, pos = 'top' as 'top' | 'bottom' | 'bot' | 'left' | 'right'): void { 95 | if (pos === 'bot') { 96 | pos = 'bottom'; 97 | } 98 | if (!v) { 99 | return; 100 | } 101 | var key = this._key(v); 102 | if (this._data[key] && this._data[key].pos === pos) { 103 | return; 104 | } 105 | else { 106 | this.remove(v); 107 | var tip = this._newTip(v, pos); 108 | if (tip) { 109 | var rect = this._arrange(tip, v, pos); 110 | this._data[key] = { tip: tip, value: v, rect: rect, pos }; 111 | tip.datum(this._data[key]); 112 | } 113 | } 114 | } 115 | 116 | public update(v: T) { 117 | var data = this._data[this._key(v)]; 118 | if (data) { 119 | this.remove(v).add(v, data.pos as any); 120 | } 121 | } 122 | 123 | public root(): ISelex { 124 | return this._root; 125 | } 126 | 127 | public flip(v: T, pos = 'top' as 'top' | 'bottom' | 'left' | 'right'): this { 128 | var key = this._key(v); 129 | this._data[key] ? this.remove(v) : this.add(v, pos); 130 | return this; 131 | } 132 | 133 | public contains(v: T): boolean { 134 | return this._key(v) in this._data; 135 | } 136 | 137 | private _arrange(tip: ISelex, v: any, pos: string, rect?: IRect): IRect { 138 | var { x, y } = this._origin(v); 139 | if (rect) { 140 | var width = rect.width, height = rect.height; 141 | } 142 | else { 143 | var node = tip.select('.content-wrap').node(); 144 | var width = node.offsetWidth, height = node.offsetHeight; 145 | } 146 | //7 is the magic number, the border of the arrow and the padding of the content 147 | if (pos === 'top' || pos === 'bottom') { 148 | tip.select('.arrow').style('margin-left', (width / 2 - 7) + 'px'); 149 | y -= 7; 150 | tip.style('left', x - width / 2 + 'px'); 151 | tip.style('top', (pos === 'top' ? y - height : y) + 'px'); 152 | } 153 | else { 154 | tip.select('.arrow').style('margin-top', (height / 2 - 7) + 'px'); 155 | x -= 7; 156 | tip.style('top', y - height / 2 + 'px'); 157 | tip.style('left', (pos === 'left' ? x - width : x) + 'px'); 158 | } 159 | if (this._border) { 160 | var border = this._border(); 161 | if (pos === 'top' || pos === 'bottom') { 162 | var min = border.x, max = border.x + border.width; 163 | this._shift(tip, min, max, x, width, 'left'); 164 | } 165 | else { 166 | var min = border.y, max = border.y + border.height; 167 | this._shift(tip, min, max, y, height, 'top'); 168 | } 169 | } 170 | return { x: x - width / 2, y: y - 7 - height, width, height }; 171 | } 172 | 173 | private _shift(tip: ISelex, min: number, max: number, v: number, size: number, tag: string) { 174 | if (v - size / 2 < min) { 175 | if (min < v - 7) { 176 | var delta = v - 7 - min; 177 | tip.select('.arrow').style('margin-' + tag, delta + 'px'); 178 | tip.style(tag, min + 'px'); 179 | } 180 | else { 181 | tip.style(tag, v - 7 + 'px'); 182 | tip.select('.arrow').style('margin-' + tag, '0px'); 183 | } 184 | } 185 | else if (v + size / 2 > max) { 186 | if (max > v + 7) { 187 | tip.style(tag, max - size + 'px'); 188 | var delta = size - max + v - 7; 189 | tip.select('.arrow').style('margin-' + tag, delta + 'px'); 190 | } 191 | else { 192 | tip.style(tag, v + 7 - size + 'px'); 193 | tip.select('.arrow').style('margin-' + tag, size - 14 + 'px'); 194 | } 195 | } 196 | } 197 | 198 | public remove(v: T): this { 199 | var key = this._key(v); 200 | if (this._data[key]) { 201 | this._data[key].tip.remove(); 202 | delete this._data[key]; 203 | } 204 | return this; 205 | } 206 | 207 | public transform() { 208 | for (var key of Object.keys(this._data)) { 209 | var { tip, value, pos, rect } = this._data[key]; 210 | this._arrange(tip, value, pos, rect); 211 | } 212 | } 213 | 214 | public content(conv: Func): this { 215 | this._content = conv; 216 | return this; 217 | } 218 | 219 | private _color(v: T): string { 220 | let color = this._background ? this._background(v).join(',') : this._rgb; 221 | return `rgba(${color}, ${this._opacity})`; 222 | } 223 | 224 | private _newTip(v: T, pos: string): ISelex { 225 | var tip = selex(document.createElement('div')).classed('tip ' + pos, true); 226 | var color = this._color(v); 227 | var arrow = tip.append('div') 228 | .classed('arrow ' + pos, true) 229 | .style('border-' + pos + '-color', color); 230 | var wrap = tip.append('div') 231 | .classed('content-wrap', true) 232 | .style('background-color', color); 233 | var content = this._content(v); 234 | if (content) { 235 | wrap.node().appendChild(content); 236 | this._root.node().appendChild(tip.node()); 237 | return tip; 238 | } 239 | else { 240 | return null; 241 | } 242 | } 243 | 244 | private _opacity = 1; 245 | public opacity(v: number): this { 246 | if (this._opacity === v) { 247 | return; 248 | } 249 | this._opacity = v; 250 | this._root.selectAll('.content-wrap').style('background-color', v => this._color(v)); 251 | for (var loc of ['top', 'left', 'right', 'bottom']) { 252 | this._root.selectAll('.arrow.' + loc).style('border-' + loc + '-color', v => this._color(v)); 253 | } 254 | return this; 255 | } 256 | } -------------------------------------------------------------------------------- /code/src/pbi/Context.ts: -------------------------------------------------------------------------------- 1 | import powerbi from "powerbi-visuals-api"; 2 | import { Roles } from './Roles'; 3 | import { Category } from './Category'; 4 | import { StringMap, sequence, Func, values, first, groupBy } from '../lava/type'; 5 | import { FormatManager, Config, FormatInstance, Binding, FormatDumper } from "./Format"; 6 | import { Persist } from "./Persist"; 7 | 8 | 9 | type FmtDict = { [P in keyof F]: FormatManager } 10 | 11 | export type PColumn = powerbi.DataViewCategoricalColumn & { values: powerbi.PrimitiveValue[] }; 12 | 13 | export class Context { 14 | public readonly roles = new Roles(); 15 | public readonly fmt = {} as FmtDict; 16 | public readonly host: powerbi.extensibility.visual.IVisualHost; 17 | 18 | private _view: powerbi.DataView; 19 | 20 | public palette(key: string): string { 21 | return this.host.colorPalette.getColor(key).value; 22 | } 23 | 24 | public isResizeVisualUpdateType(options: powerbi.extensibility.visual.VisualUpdateOptions): boolean { 25 | return options.type === 4 || options.type === 32 || options.type === 36; 26 | } 27 | 28 | public persist(oname: O, pname: P, v: F[O][P]) { 29 | this.host.persistProperties({ 30 | merge: [{ 31 | objectName: oname as string, 32 | properties: { [pname]: v }, 33 | selector: null 34 | }] 35 | }); 36 | } 37 | 38 | original(oname: O): F[O]; 39 | original(oname: O, pname: P): F[O][P]; 40 | original(oname: string, pname?: string): any { 41 | const view = this._view; 42 | if (view && view.metadata && view.metadata.objects) { 43 | const obj = view.metadata.objects[oname]; 44 | if (pname) { 45 | return (obj || {})[pname]; 46 | } 47 | else { 48 | return obj; 49 | } 50 | } 51 | return undefined; 52 | } 53 | 54 | public binding(oname: O, pname: P): Binding { 55 | return this.fmt[oname].binding(pname); 56 | } 57 | 58 | public dumper(oname: O): FormatDumper { 59 | return this.fmt[oname].dumper(); 60 | } 61 | 62 | public labels(binding: Binding, values: StringMap): FormatInstance[] { 63 | if (!this.cat(binding.role)) { 64 | return []; 65 | } 66 | const role = binding.role, group = this.item(binding.fmt.oname as any, binding.pname as any) as any; 67 | const result = [] as FormatInstance[]; 68 | const cat = this.cat(role), rows = cat.distincts(), key = cat.key; 69 | const labels = cat.row2label(rows), key2rows = groupBy(rows, group); 70 | for (const k in key2rows) { 71 | const rows = key2rows[k], row = first(rows, r => key(r) in values); 72 | if (row !== undefined) { 73 | result.push({ row, value: values[key(row)], name: rows.map(r => labels[r]).join(','), key: k }); 74 | } 75 | else { 76 | result.push({ row: rows[0], name: rows.map(r => labels[r]).join(','), key: k }); 77 | } 78 | } 79 | result.forEach(r => r.auto = r.name); 80 | return result; 81 | } 82 | 83 | public config(oname: O, pname: P): Config { 84 | return this.fmt[oname].config(pname); 85 | } 86 | 87 | public item(oname: O, pname: P) { 88 | return this.fmt[oname].item(pname); 89 | } 90 | 91 | public dirty(onames?: (keyof F)[]): boolean { 92 | if (onames === undefined) { 93 | for (const k in this.fmt) { 94 | if (this.fmt[k].dirty()) { 95 | return true; 96 | } 97 | } 98 | } 99 | else { 100 | for (const k of onames) { 101 | if (this.fmt[k].dirty()) { 102 | return true; 103 | } 104 | } 105 | } 106 | return false; 107 | } 108 | 109 | public data(r: R): T[] { 110 | const c = first(this._view.categorical.values, c => c.source.roles[r], null) 111 | || first(this._view.categorical.categories, c => c.source.roles[r], null); 112 | return c ? c.values as any : null; 113 | } 114 | 115 | public nums(r: R): number[] { 116 | return this.data(r); 117 | } 118 | 119 | public strs(r: R): string[] { 120 | return this.data(r); 121 | } 122 | 123 | columns(role: R, view?: powerbi.DataView): PColumn[] { 124 | view = view || this._view; 125 | if (!view || !view.categorical) { 126 | return null; 127 | } 128 | let result = [] as PColumn[]; 129 | for (let col of view.categorical.categories) { 130 | if (col.source.roles[role]) { 131 | result.push(col); 132 | } 133 | } 134 | for (let col of view.categorical.values) { 135 | if (col.source.roles[role]) { 136 | result.push(col); 137 | } 138 | } 139 | return result; 140 | } 141 | 142 | constructor(host: powerbi.extensibility.visual.IVisualHost, dft: F) { 143 | Persist.HOST = host; 144 | this.host = host; 145 | this._catCache = {}; 146 | for (const oname in dft) { 147 | this.fmt[oname] = new FormatManager(oname, dft[oname], this); 148 | } 149 | } 150 | 151 | public group(...roles: R[]): number[][] { 152 | if (roles.every(r => !this.cat(r))) { 153 | return [this.rows()]; 154 | } 155 | const keys = [] as Func[]; 156 | for (let r of roles) { 157 | if (this.cat(r)) { 158 | keys.push(this.cat(r).key); 159 | } 160 | } 161 | const id = keys.length === 1 ? keys[0] : (r: number) => keys.map(k => k(r)).join(' ');; 162 | let cache = {} as StringMap; 163 | for (let row of this.rows()) { 164 | const key = id(row); 165 | let rows = cache[key]; 166 | if (!rows) { 167 | rows = cache[key] = []; 168 | } 169 | rows.push(row); 170 | } 171 | return values(cache); 172 | } 173 | 174 | public rows() { 175 | if (this._rows !== null) { 176 | return this._rows; 177 | } 178 | if (this._view && this._view.categorical.categories[0]) { 179 | this._rows = sequence(0, this._view.categorical.categories[0].values.length); 180 | } 181 | else { 182 | this._rows = []; 183 | } 184 | return this._rows; 185 | } 186 | 187 | private _rows: number[]; 188 | 189 | private _fmt: { [P in keyof F]: Readonly }; 190 | public get meta(): Readonly<{ [P in keyof F]: Readonly }> { 191 | return this._fmt; 192 | } 193 | 194 | public update(view: powerbi.DataView): this { 195 | this._view = view; 196 | this._catCache = {}; 197 | this._rows = null; 198 | //update other things below 199 | this.roles.update(view); 200 | const format = (view.metadata.objects || {}) as any as F; 201 | this._fmt = {} as any; 202 | for (const oname in this.fmt) { 203 | this._fmt[oname] = this.fmt[oname].update(format[oname]); 204 | } 205 | return this; 206 | } 207 | 208 | public reader(r: R): Func { 209 | let c = first(this._view.categorical.values, c => c.source.roles[r], null) 210 | || first(this._view.categorical.categories, c => c.source.roles[r], null); 211 | if (c) { 212 | return r => (c.values[r] as any as T); 213 | } 214 | else { 215 | return null; 216 | } 217 | } 218 | 219 | public key(...roles: R[]): Func { 220 | if (roles.length === 1) { 221 | if (this.cat(roles[0])) { 222 | return this.cat(roles[0]).key; 223 | } 224 | else { 225 | return r => ""; 226 | } 227 | } 228 | roles = roles.filter(r => this.cat(r)); 229 | const keys = roles.map(r => this.cat(r).key); 230 | return r => keys.map(k => k(r)).join('_'); 231 | } 232 | 233 | public type(r: R): powerbi.ValueTypeDescriptor { 234 | if (this.cat(r)) { 235 | return this.cat(r).column.source.type; 236 | } 237 | else { 238 | return {}; 239 | } 240 | } 241 | 242 | private _catCache: StringMap; 243 | public cat(r: R): Category { 244 | if (r in this._catCache) { 245 | return this._catCache[r]; 246 | } 247 | else { 248 | const column = this.roles.column(r); 249 | if (column) { 250 | return this._catCache[r] = new Category(column, this.host); 251 | } 252 | else { 253 | return this._catCache[r] = null; 254 | } 255 | } 256 | } 257 | } -------------------------------------------------------------------------------- /code/src/lava/flowmap/shape.ts: -------------------------------------------------------------------------------- 1 | import { ILocation, IBound, Converter } from '../bingmap'; 2 | import { Func, StringMap, values } from '../type'; 3 | import { Key, IPathPoint, IPoint, ILayout, IPath, layout } from './algo'; 4 | import { extent } from 'd3-array'; 5 | import { arc } from './arc'; 6 | import { ISelex } from '../d3'; 7 | 8 | import { $state } from './app'; 9 | 10 | const map20 = new Converter(20); 11 | 12 | function pointConverter(level: number) { 13 | var zoom = $state.mapctl.map.getZoom(); 14 | if (zoom === level) { 15 | return null; 16 | } 17 | let factor = map20.factor(zoom); 18 | return (input: IPathPoint, output: number[]) => { 19 | output[0] = input[0] * factor; 20 | output[1] = input[1] * factor; 21 | }; 22 | } 23 | 24 | class LinePath implements IPath { 25 | id: Key; 26 | leafs: Key[]; 27 | 28 | private _width: number; 29 | private _path = ''; 30 | 31 | constructor(path: string, key: number, public weight: number) { 32 | this.id = key; 33 | this._width = weight; 34 | this.leafs = [key]; 35 | this._path = path; 36 | } 37 | 38 | d(tran?: (input: IPathPoint, output: number[]) => void): string { 39 | return this._path; 40 | } 41 | 42 | width(scale?: Func): number { 43 | if (scale) { 44 | this._width = scale(this.weight); 45 | } 46 | return this._width; 47 | } 48 | minLatitude: number; 49 | maxLatitude: number; 50 | } 51 | 52 | class helper { 53 | public static initPaths(root: ISelex, shape: IShape) { 54 | let conv = pointConverter(null); 55 | root.selectAll('*').remove(); 56 | root.selectAll('.base').data(shape.paths()).enter().append('path'); 57 | root.selectAll('path').att.class('base flow').att.d(p => p.d(conv)) 58 | .att.stroke_linecap('round').att.fill('none'); 59 | } 60 | 61 | public static line(src: ILocation, tlocs: ILocation[], trows: number[], weis: number[]) { 62 | let all = tlocs.concat(src); 63 | let bound = map20.points(all); 64 | let spnt = bound.points.pop(); 65 | let pre = 'M ' + Math.round(spnt.x) + ' ' + Math.round(spnt.y); 66 | let paths = {} as StringMap; 67 | let row2tar = {} as StringMap; 68 | for (let i = 0; i < bound.points.length; i++) { 69 | row2tar[i] = tlocs[i]; 70 | let tpnt = bound.points[i], trow = trows[i]; 71 | var str = pre + ' L ' + Math.round(tpnt.x) + ' ' + Math.round(tpnt.y); 72 | paths[trow] = new LinePath(str, trow, weis[i]); 73 | } 74 | return { paths, bound: bound as IBound }; 75 | } 76 | 77 | public static arc(src: ILocation, tlocs: ILocation[], trows: number[], weis: number[]) { 78 | let slon = src.longitude, slat = src.latitude; 79 | let scoord = { x: 0, y: slat }, tcoord = { x: 0, y: 0 }; 80 | let all = tlocs.concat(src); 81 | // let yext = extent(all, p => p.latitude); 82 | let bound = $state.mapctl.bound(all); 83 | let anchor = bound.anchor; 84 | anchor.latitude = slat; 85 | let alon = anchor.longitude; 86 | let bias = map20.x(slon) - map20.x(alon); 87 | if (Math.abs(alon - slon) > 180) { 88 | if (alon > slon) { 89 | bias = map20.x(slon + 360 - alon - 180); 90 | } 91 | else { 92 | bias = 0 - map20.x(alon + 360 - slon - 180); 93 | } 94 | } 95 | let paths = {} as StringMap; 96 | let row2tar = {} as StringMap; 97 | let minlat = Number.POSITIVE_INFINITY; 98 | let maxlat = Number.NEGATIVE_INFINITY; 99 | for (var i = 0, len = tlocs.length; i < len; i++) { 100 | let t = tlocs[i], tlon = t.longitude, trow = trows[i]; 101 | row2tar[trow] = t; 102 | let miny = Number.POSITIVE_INFINITY; 103 | let maxy = Number.NEGATIVE_INFINITY; 104 | tcoord.y = t.latitude; 105 | if (Math.abs(tlon - slon) < 180) { 106 | tcoord.x = tlon - slon; 107 | } 108 | else { 109 | if (tlon < slon) { 110 | tcoord.x = 360 - slon + tlon; 111 | } 112 | else { 113 | tcoord.x = tlon - slon - 360; 114 | } 115 | } 116 | if (!arc) { 117 | debugger; 118 | } 119 | var cnt = Math.max(Math.round(Math.abs(tcoord.x / 4)), 10); 120 | var coords = arc(scoord, tcoord, cnt); 121 | var sx = map20.x(0), sy = map20.y(scoord.y); 122 | var str = 'M ' + Math.round(bias) + ' 0'; 123 | for (var pair of coords) { 124 | let [px, py] = pair; 125 | if (py < miny) { 126 | miny = py; 127 | } 128 | if (py > maxy) { 129 | maxy = py; 130 | } 131 | var dx = Math.round(map20.x(px) - sx + bias); 132 | var dy = Math.round(map20.y(py) - sy); 133 | str += ' L ' + dx + ' ' + dy; 134 | } 135 | var apath = new LinePath(str, trow, weis[i]); 136 | minlat = Math.min(minlat, miny); 137 | maxlat = Math.max(maxlat, maxy); 138 | apath.minLatitude = miny; 139 | apath.maxLatitude = maxy; 140 | paths[trow] = apath; 141 | } 142 | bound.margin.north = maxlat - slat; 143 | bound.margin.south = slat - minlat; 144 | return { paths, bound }; 145 | } 146 | } 147 | 148 | export interface IShape { 149 | rewidth(): void; 150 | calc(weight: (row: number) => number): number[]; 151 | transform(map: Microsoft.Maps.Map, pzoom: number): void; 152 | bound: IBound; 153 | source: ILocation; 154 | paths(): IPath[]; 155 | } 156 | 157 | export function build(type: 'straight' | 'flow' | 'arc', d3: ISelex, src: ILocation, tars: ILocation[], trows: number[], weis: number[]): IShape { 158 | switch (type) { 159 | case 'flow': 160 | return new FlowShape(d3, src, tars, trows, weis); 161 | case 'arc': 162 | const arc = helper.arc(src, tars, trows, weis); 163 | return new LineShape(d3, src, arc.paths, arc.bound); 164 | case 'straight': 165 | const line = helper.line(src, tars, trows, weis); 166 | return new LineShape(d3, src, line.paths, line.bound); 167 | } 168 | } 169 | 170 | class FlowShape implements IShape { 171 | public readonly d3: ISelex; 172 | public readonly bound: IBound; 173 | private _layout: ILayout; 174 | private _row2tar = {} as StringMap; 175 | public readonly source: ILocation; 176 | constructor(d3: ISelex, src: ILocation, tars: ILocation[], trows: number[], weis?: number[]) { 177 | this.source = src; 178 | const area = map20.points([src].concat(tars)); 179 | const points = area.points; 180 | const source = points.shift() as IPoint; 181 | source.key = $state.config.source(trows[0]); 182 | for (let i = 0; i < points.length; i++){ 183 | (points[i] as IPoint).key = trows[i]; 184 | this._row2tar[trows[i]] = tars[i]; 185 | } 186 | this._layout = layout(source, points, weis); 187 | helper.initPaths(d3, this); 188 | this.d3 = d3; 189 | this.bound = area; 190 | } 191 | 192 | paths(): IPath[] { 193 | return this._layout.paths(); 194 | } 195 | 196 | calc(weight: (row: number) => number): number[] { 197 | weight && this._layout.build(weight); 198 | return extent(this._layout.paths().map(p => p.weight)); 199 | } 200 | 201 | rewidth() { 202 | const conv = pointConverter(null); 203 | this.d3.selectAll('path') 204 | .att.stroke_width(p => p.width($state.width)) 205 | .att.d(p => p.d(conv)); 206 | } 207 | 208 | transform(map: Microsoft.Maps.Map, pzoom: number) { 209 | const conv = pointConverter(pzoom); 210 | conv && this.d3.selectAll('.flow').att.d(p => p.d(conv)); 211 | } 212 | } 213 | 214 | class LineShape implements IShape { 215 | public readonly d3: ISelex; 216 | public readonly bound: IBound; 217 | public readonly source: ILocation; 218 | 219 | private _row2Path = {} as StringMap; 220 | 221 | constructor(d3: ISelex, src: ILocation, row2Path: StringMap, bound: IBound) { 222 | this.source = src; 223 | this.d3 = d3; 224 | this._row2Path = row2Path; 225 | this.bound = bound; 226 | helper.initPaths(d3, this); 227 | } 228 | 229 | calc(weight: (row: number) => number): number[] { 230 | if (weight) { 231 | for (let r in this._row2Path) { 232 | let path = this._row2Path[r]; 233 | path.weight = weight(+r); 234 | } 235 | } 236 | return extent(values(this._row2Path).map(p => p.weight)); 237 | } 238 | 239 | rewidth() { 240 | const factor = map20.factor($state.mapctl.map.getZoom()); 241 | const width = (v: number) => $state.width(v) / factor; 242 | this.d3.att.scale(factor); 243 | this.d3.selectAll('path').att.stroke_width(p => p.width(width)); 244 | } 245 | 246 | transform(map: Microsoft.Maps.Map, pzoom: number) { 247 | this.rewidth(); 248 | } 249 | 250 | paths(): IPath[] { 251 | return values(this._row2Path); 252 | } 253 | } -------------------------------------------------------------------------------- /code/src/lava/bingmap/converter.ts: -------------------------------------------------------------------------------- 1 | import { defaultZoom } from './controller'; 2 | import { ISize, IPoint, Func, StringMap, clamp } from '../type'; 3 | 4 | export interface ILocation { 5 | latitude : number; 6 | longitude: number; 7 | type?: string; 8 | name?: string; 9 | address?: string; 10 | } 11 | 12 | export interface IArea { 13 | points: IPoint[]; 14 | anchor: ILocation; 15 | margin: { south: number, north: number, east: number, west: number }; 16 | offsets: number[]; 17 | scale(zoom: number): number; 18 | } 19 | 20 | export interface IPoints { 21 | points: IPoint[], 22 | anchor: ILocation, 23 | margin: { south: number, north: number, east: number, west: number }; 24 | offsets: StringMap; 25 | } 26 | 27 | export interface IBound { 28 | anchor: ILocation; 29 | margin: { south: number, north: number, east: number, west: number }; 30 | offsets?: number[]; 31 | } 32 | 33 | export function bound(data: ILocation[]): IBound { 34 | let anch = anchor(data); 35 | if (!anch) { 36 | return null; 37 | } 38 | let { longitude: alon, latitude: alat, positive } = anch; 39 | let west = 0, east = 0, south = 0, north = 0, dcnt = 0; 40 | let offsets = []; 41 | for (let i = 0; i < data.length; i++) { 42 | let d = data[i]; 43 | if (!d) { 44 | continue; 45 | } 46 | dcnt++; 47 | let long = d.longitude, lati = d.latitude; 48 | if (lati > alat) { 49 | north = Math.max(north, lati - alat); 50 | } 51 | else { 52 | south = Math.max(south, alat - lati); 53 | } 54 | if (positive) { 55 | if (long > alon) { 56 | east = Math.max(east, long - alon); 57 | } 58 | else { 59 | if (alon - long > long + 360 - alon) { 60 | //shifted 61 | east = Math.max(long + 360 - alon, east); 62 | offsets[i] = 1; 63 | } 64 | else { 65 | west = Math.max(alon - long, west); 66 | } 67 | } 68 | } 69 | else {//negative 70 | if (long < alon) { 71 | west = Math.max(alon - long, west); 72 | } 73 | else { 74 | if (alon - (long - 360) < long - alon) { 75 | //shifted 76 | west = Math.max(alon - (long - 360), west); 77 | offsets[i] = -1; 78 | } 79 | else { 80 | east = Math.max(long - alon, east); 81 | } 82 | } 83 | } 84 | } 85 | if (dcnt <= 1) { 86 | east = west = south = north = 0.1; 87 | } 88 | return { 89 | anchor: { longitude: alon, latitude: alat }, 90 | margin: { east, west, south, north }, 91 | offsets 92 | } 93 | } 94 | 95 | export function fitOptions(bounds: IBound[], view: ISize): Microsoft.Maps.IViewOptions { 96 | bounds = (bounds || []).filter(a => !!a); 97 | if (bounds.length === 0) { 98 | return { 99 | zoom: defaultZoom(view.width, view.height), 100 | center: new Microsoft.Maps.Location(0, 0) 101 | }; 102 | } 103 | let n = Math.max(...bounds.map(a => a.anchor.latitude + a.margin.north)); 104 | let s = Math.min(...bounds.map(a => a.anchor.latitude - a.margin.south)); 105 | let w = Math.min(...bounds.map(a => a.anchor.longitude - a.margin.west)); 106 | let e = Math.max(...bounds.map(a => a.anchor.longitude + a.margin.east)); 107 | s = clamp(s, -88, 88); 108 | n = clamp(n, -88, 88); 109 | let rect = Microsoft.Maps.LocationRect.fromCorners( 110 | new Microsoft.Maps.Location(n, w), 111 | new Microsoft.Maps.Location(s, e) 112 | ); 113 | let height = Math.abs(helper.lat2y(n, 20) - helper.lat2y(s, 20)); 114 | let width = helper.lon2x(rect.width - 180, 20); 115 | for (var level = 20; level > 1; level--) { 116 | if (width < view.width && height < view.height) { 117 | break; 118 | } 119 | width /= 2; 120 | height /= 2; 121 | } 122 | return { zoom: level, center: rect.center }; 123 | } 124 | 125 | export function anchorPixel(m: Microsoft.Maps.Map, bound: IBound): IPoint { 126 | let level = m.getZoom(), { anchor, margin } = bound; 127 | let loc = new Microsoft.Maps.Location(anchor.latitude, anchor.longitude); 128 | let pix = m.tryLocationToPixel(loc) as IPoint; 129 | let east = helper.lon2x(margin.east - 180, level); 130 | let west = helper.lon2x(margin.west - 180, level); 131 | let width = m.getWidth(); 132 | let left = pix.x + width / 2 - west; 133 | let size = helper.mapSize(level); 134 | let half = east / 2 + west / 2; 135 | if (left < 0) { 136 | if (width - left - size > half) { 137 | pix.x += size; 138 | return pix; 139 | } 140 | return pix; 141 | } 142 | if (left > width - half) { 143 | if (left + half - size > 0) { 144 | pix.x -= size; 145 | return pix; 146 | } 147 | if (left > width) { 148 | pix.x -= size; 149 | return pix; 150 | } 151 | return pix; 152 | } 153 | return pix; 154 | } 155 | 156 | //allow to have null or undefined in data 157 | export function anchor(data: ILocation[]): ILocation & { positive: boolean } { 158 | if (!data || data.length === 0) { 159 | return null; 160 | } 161 | let pcnt = 0, ncnt = 0, psum = 0, nsum = 0, latsum = 0; 162 | for (let i = 0; i < data.length; i++) { 163 | let d = data[i]; 164 | if (!d) { 165 | continue; 166 | } 167 | let long = d.longitude, lati = d.latitude; 168 | latsum += lati; 169 | if (long > 0) { 170 | pcnt++; 171 | psum += long; 172 | } 173 | else { 174 | ncnt++; 175 | nsum += long; 176 | } 177 | } 178 | if (pcnt === 0 && ncnt === 0) { 179 | return null; 180 | } 181 | let positive = psum + nsum > 0; 182 | return { 183 | longitude: positive ? psum / pcnt : nsum / ncnt, 184 | latitude: latsum / data.length, 185 | positive: positive 186 | } 187 | } 188 | 189 | export function area(data: ILocation[], level = 20): IArea { 190 | let area = bound(data) as any as IArea; 191 | if (!bound) { 192 | return null; 193 | } 194 | let offsets = area.offsets; 195 | let { longitude: alon, latitude: alat } = area.anchor; 196 | let period = helper.lon2x(180, level); 197 | 198 | let ax = helper.lon2x(alon, level), ay = helper.lat2y(alat, level); 199 | let points = [] as IPoint[]; 200 | for (let i = 0; i < data.length; i++) { 201 | let d = data[i]; 202 | if (!d) { 203 | points.push(null); 204 | continue; 205 | } 206 | let x = Math.round(helper.lon2x(d.longitude, level) - ax); 207 | let y = Math.round(helper.lat2y(d.latitude, level) - ay); 208 | points.push({ x: x + (offsets[i] || 0) * period, y }); 209 | } 210 | area.points = points; 211 | area.scale = z => Math.pow(2, z - level); 212 | return area; 213 | } 214 | 215 | export class Converter { 216 | private _level: number; 217 | 218 | constructor(level: number) { 219 | this._level = level; 220 | } 221 | 222 | public factor(zoom: number): number { 223 | return Math.pow(2, zoom - this._level); 224 | } 225 | 226 | public line(data: ILocation[]): IArea { 227 | let ret = this.points(data), half = helper.lon2x(0, this._level); 228 | let points = ret.points, prev = null as IPoint; 229 | for (let p of points) { 230 | if (!p) continue; 231 | if (prev === null) { 232 | prev = p; 233 | } 234 | else { 235 | let delta = prev.x - p.x; 236 | if (Math.abs(delta) > half) { 237 | p.x += (delta > 0 ? 2 : -2) * half; 238 | } 239 | prev = p; 240 | } 241 | } 242 | return ret; 243 | } 244 | 245 | public points(data: ILocation[]): IArea { 246 | return area(data, this._level); 247 | } 248 | 249 | public x(lng: number): number { 250 | return helper.lon2x(lng, this._level); 251 | } 252 | 253 | public y(lat: number): number { 254 | return helper.lat2y(lat, this._level); 255 | } 256 | } 257 | 258 | namespace helper { 259 | let _mapSizeCache = [0, 0]; 260 | function _map2Screen(v: number, level: number): number { 261 | var size = mapSize(level); 262 | return Math.min(v * size + 0.5, size - 1); 263 | } 264 | 265 | export function mapSize(level: number): number { 266 | var size = 0; 267 | if (level === _mapSizeCache[0]) { 268 | size = _mapSizeCache[1]; 269 | } 270 | else { 271 | if (level === 23) 272 | size = 2147483648; 273 | else if (Math.floor(level) == level) 274 | size = 256 << level; 275 | else 276 | size = 256 * Math.pow(2, level); 277 | _mapSizeCache = [level, size]; 278 | } 279 | return size; 280 | } 281 | 282 | export function lat2y(lat: number, level: number): number { 283 | if (lat < -85.05112878) lat = -85.05112878; 284 | if (lat > 85.05112878) lat = 85.05112878; 285 | let sin = Math.sin(lat * Math.PI / 180); 286 | let y = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI); 287 | return _map2Screen(y, level); 288 | } 289 | 290 | export function lon2x(lon: number, level: number): number { 291 | if (lon < -180) lon = -180; 292 | if (lon > 180) lon = 180; 293 | return _map2Screen((lon + 180) / 360, level); 294 | } 295 | 296 | export function loc(pixelX: number, pixelY: number, level: number): ILocation { 297 | var mapSize = mapSize(level); 298 | var x = Math.min(pixelX, mapSize - 1) / mapSize - 0.5; 299 | var y = 0.5 - Math.min(pixelY, mapSize - 1) / mapSize; 300 | var latitude = 90 - 360 * Math.atan(Math.exp(-y * 2 * Math.PI)) / Math.PI; 301 | var longitude = 360 * x; 302 | return { latitude, longitude }; 303 | } 304 | } -------------------------------------------------------------------------------- /code/src/lava/flowmap/app.ts: -------------------------------------------------------------------------------- 1 | import { Func, StringMap, Action, IRect, dict, keys } from "../type"; 2 | import { selex } from "../d3"; 3 | import { Controller, MapFormat, GeoQuery, ILocation } from '../bingmap'; 4 | import { Config } from "./config"; 5 | import { extent } from "d3-array"; 6 | import { scaleLinear, interpolateRgb, scaleSqrt, scaleIdentity } from "d3"; 7 | import { Legend } from "./legend"; 8 | import * as flows from './flow'; 9 | import * as pins from './pin'; 10 | import * as pies from './pie'; 11 | import * as popups from './popup'; 12 | 13 | export { Config } from './config'; 14 | 15 | interface Issue { 16 | unlocate?: string; 17 | selflink?: string; 18 | negative?: string; 19 | } 20 | 21 | class State { 22 | public get border(): IRect { 23 | return { x: 0, y: 0, height: this.mapctl.map.getHeight(), width: this.mapctl.map.getWidth() }; 24 | } 25 | config = null as Config; 26 | issues = {} as StringMap; 27 | geocode = {} as StringMap; 28 | color = null as Func; 29 | width = null as Func; 30 | mapctl = null as Controller; 31 | loc(addr: string) { 32 | if (addr in this.config.injections) { 33 | return this.config.injections[addr]; 34 | } 35 | if (addr in this.geocode) { 36 | return this.geocode[addr]; 37 | } 38 | return null; 39 | } 40 | reset(config: Config) { 41 | this.config = config; 42 | } 43 | pixel(addr: string, ref?: Microsoft.Maps.PixelReference) { 44 | return this.mapctl.pixel(this.loc(addr), ref); 45 | } 46 | } 47 | 48 | export const events = { 49 | doneGeocoding: null as Func, void>, 50 | flow: flows.events, 51 | pin: pins.events, 52 | pie: pies.events, 53 | popup: popups.events 54 | }; 55 | 56 | export let $state = new State(); 57 | 58 | let legend = null as Legend; 59 | 60 | export function init(div: HTMLElement, mapFmt: MapFormat, initialPopups: string[], then: Func) { 61 | popups.reset(initialPopups); 62 | const root = selex(div); 63 | root.append('div').att.id('view').sty.width('100%').sty.height('100%'); 64 | root.append('div').att.id('mark'); 65 | root.append('div').att.id('legend').sty.position('absolute').sty.top('0px').sty.left('0px'); 66 | root.append('div').att.id('warn'); 67 | legend = new Legend(root.select('#legend')); 68 | const ctl = $state.mapctl = new Controller('#view'); 69 | ctl.restyle(mapFmt, _ => { 70 | ctl.svg.sty.cursor('default').sty.pointer_events('visiblePainted'); 71 | ctl.add(flows.init(ctl.svg.append('g'))); 72 | ctl.add(pins.init(ctl.svg.append('g'))); 73 | ctl.add(pies.init(ctl.svg.append('g'))); 74 | ctl.add({ resize: _ => legend.resize() }); 75 | ctl.add(popups.init(root.select('#mark'))); 76 | 77 | flows.events.hover = rows => { 78 | if (!rows) { 79 | pies.hover(null); 80 | } 81 | else { 82 | let srcs = dict(rows, r => $state.config.source(r)); 83 | let tars = dict(rows, r => $state.config.target(r)); 84 | pies.hover(keys(srcs, tars)); 85 | } 86 | }; 87 | then(ctl); 88 | }); 89 | } 90 | 91 | export function tryFitView() { 92 | const bounds = flows.bounds(); 93 | if (bounds.length) { 94 | const source = flows.sources(); 95 | let backup = null as ILocation; 96 | let area = -1; 97 | for (let i = 0; i < bounds.length; i++) { 98 | let { margin } = bounds[i]; 99 | let a = margin.south - margin.east; 100 | a *= margin.north - margin.south; 101 | if (Math.abs(a) > area) { 102 | area = Math.abs(a); 103 | backup = source[i]; 104 | } 105 | } 106 | $state.mapctl.fitView(bounds, backup); 107 | } 108 | } 109 | 110 | let geoquery = null as GeoQuery; 111 | 112 | function queue(groups: number[][], then: Action) { 113 | const next = () => { 114 | if (groups.length === 0) { 115 | legend.info(null); 116 | geoquery && geoquery.cancel(); 117 | geoquery = null; 118 | if (events.doneGeocoding) { 119 | events.doneGeocoding($state.geocode); 120 | } 121 | then && then(); 122 | return; 123 | } 124 | const flow = groups.shift(), source = $state.config.source(flow[0]); 125 | let addrs = [source].concat(flow.map(r => $state.config.target(r))); 126 | addrs = addrs.filter(d => !$state.loc(d)); 127 | let total = addrs.length, sofar = 0; 128 | if (addrs.length === 0) { 129 | addGroup(flow); 130 | next(); 131 | return; 132 | } 133 | sofar = total - addrs.length; 134 | geoquery = new GeoQuery(addrs); 135 | const cancel = () => { 136 | legend.d3('info').sty.cursor('default'); 137 | geoquery && geoquery.cancel(); 138 | geoquery = null; 139 | legend.info(null); 140 | addGroup(flow); 141 | if (events.doneGeocoding) { 142 | events.doneGeocoding($state.geocode); 143 | } 144 | then && then(); 145 | }; 146 | geoquery.run(loc => { 147 | sofar++; 148 | if (loc) { 149 | $state.geocode[loc.address] = loc; 150 | } 151 | legend.info(`Geocoding ${sofar}/${total} (click to cancel): ${loc && loc.address}`); 152 | // console.log(`Geocoding ${sofar}/${total} (click to cancel): ${loc && loc.address}`); 153 | legend.d3('info').sty.cursor('pointer').on('click', cancel); 154 | if (sofar === total && geoquery) { 155 | addGroup(flow); 156 | next(); 157 | legend.d3('info').sty.cursor('default').on('click', null); 158 | } 159 | }); 160 | }; 161 | next(); 162 | } 163 | 164 | 165 | export function reset(cfg: Config, then?: Action) { 166 | if (geoquery) { 167 | //cancel last session query 168 | geoquery.cancel(); 169 | geoquery = null; 170 | } 171 | $state.reset(cfg); 172 | $state.issues = {}; 173 | legend.resize(); 174 | legend.clear(); 175 | rawGroups = []; 176 | allValids = []; 177 | flows.clear(); 178 | pins.clear(); 179 | pies.clear(); 180 | popups.clear(); 181 | if (cfg.error) { 182 | legend.info(cfg.error); 183 | } 184 | else { 185 | queue(cfg.groups, then); 186 | } 187 | } 188 | 189 | export function repaint(cfg: Config, type: 'flow' | 'banner' | 'legend' | 'bubble' | 'map') { 190 | $state.reset(cfg); 191 | if (type === 'flow') { 192 | resetColor(); 193 | resetWidth(); 194 | legend.resize(); 195 | pies.reset(allValids); 196 | flows.reformat(true, true); 197 | popups.repaint(); 198 | } 199 | else if (type === 'legend') { 200 | legend.resize(); 201 | resetColor(); 202 | resetWidth(); 203 | legend.resize(); 204 | } 205 | else if (type === 'bubble') { 206 | pies.reset(allValids); 207 | popups.repaint(); 208 | } 209 | else if (type === 'banner') { 210 | popups.repaint(); 211 | } 212 | else { 213 | $state.mapctl.restyle(cfg.map); 214 | } 215 | } 216 | 217 | let rawGroups = [] as number[][]; 218 | let allValids = [] as number[]; 219 | function addGroup(group: number[]) { 220 | rawGroups.push(group); 221 | if ($state.config.advance.relocate) { 222 | pins.reset(rawGroups); 223 | return; 224 | } 225 | const source = $state.config.source(group[0]); 226 | if (!$state.loc(source)) { 227 | $state.issues[group[0]] = { unlocate: source }; 228 | return; 229 | } 230 | const groupValid = [] as number[]; 231 | const width = $state.config.weight.conv; 232 | for (let row of group) { 233 | const target = $state.config.target(row); 234 | const issue = {} as Issue; 235 | if (target === source) { 236 | ($state.issues[row] = issue).selflink = target; 237 | allValids.push(row); 238 | } 239 | else if (!$state.loc(target)) { 240 | ($state.issues[row] = issue).unlocate = target; 241 | } 242 | else if (+width(row) <= 0) { 243 | ($state.issues[row] = issue).negative = target; 244 | } 245 | else { 246 | groupValid.push(row); 247 | allValids.push(row); 248 | } 249 | } 250 | flows.add(groupValid); 251 | resetColor(); 252 | resetWidth(); 253 | flows.reformat(true, true); 254 | legend.resize(); 255 | pies.reset(allValids); 256 | popups.repaint(); 257 | } 258 | 259 | function resetWidth() { 260 | const weight = $state.config.weight; 261 | const domain = flows.reweight(weight.conv), [dmin, dmax] = domain; 262 | let invert = null as Func; 263 | if ('max' in weight) { 264 | const { min, max } = weight, range = [min, max]; 265 | if (weight.scale === 'log') { 266 | let exp = 0.5, pow = scaleSqrt().domain([0, dmax]).range([0, max]); 267 | while (pow(dmin) > +min && exp < 1.1) { 268 | pow.exponent(exp += 0.1); 269 | } 270 | if (pow(dmin) > min) { 271 | $state.width = pow; 272 | invert = pow.invert.bind(pow); 273 | } 274 | else { 275 | const lin = scaleLinear().domain([pow(dmin), max]).range(range); 276 | $state.width = w => lin(pow(w)); 277 | invert = r => pow.invert(lin.invert(r)); 278 | } 279 | } 280 | else { 281 | const lin = scaleLinear().domain([0, dmax]).range([0, max]); 282 | if (lin(dmin) < min) { 283 | lin.domain(domain).range(range); 284 | } 285 | $state.width = lin; 286 | invert = lin.invert.bind(lin); 287 | } 288 | legend.rewidth({ invert, scale: $state.width, dmax }); 289 | } 290 | else if ('unit' in weight) { 291 | debugger; 292 | if (weight.unit === null) { 293 | weight.unit = dmin === dmax ? 3 / dmin : 25 / dmax; 294 | } 295 | const lin = scaleLinear().domain(domain).range(domain.map(d => d * weight.unit)); 296 | $state.width = lin; 297 | invert = lin.invert.bind(lin); 298 | legend.rewidth({ invert, scale: lin, dmax }); 299 | } 300 | else if (weight.scale === null) { 301 | $state.width = scaleIdentity(); 302 | invert = scaleIdentity(); 303 | legend.rewidth({ distinct: $state.config.legend.widthLabels }); 304 | } 305 | } 306 | 307 | function resetColor() { 308 | if ($state.config.legend.colorLabels) { 309 | legend.recolor({ distinct: $state.config.legend.colorLabels }); 310 | } 311 | if ($state.config.color.max) { 312 | //smooth, so color function has to be row=>number 313 | const value = $state.config.color; 314 | const domain = extent(allValids, r => value(r) as number); 315 | const range = [value.min, value.max]; 316 | const scale = scaleLinear().domain(domain).range(range) 317 | .interpolate(interpolateRgb).clamp(true); 318 | $state.color = r => scale(value(r) as number); 319 | if (!$state.config.legend.colorLabels) { 320 | legend.recolor({ domain, range }); 321 | } 322 | } 323 | else { 324 | $state.color = $state.config.color as Func; 325 | } 326 | } -------------------------------------------------------------------------------- /code/src/lava/flowmap/arc.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | //////////////////////////////////////////////////////////////////////////////////////// 4 | //////////////////////////////////////////////////////////////////////////////////////// 5 | //copied from https://github.com/springmeyer/arc.js 6 | //add the following function to work with other ts files 7 | type Point = { x: number, y: number }; 8 | export function arc(s: Point, t: Point, cnt: number): number[][] { 9 | return new GreatCircle(s, t, undefined).Arc(cnt).geometries[0].coords as number[][]; 10 | } 11 | //////////////////////////////////////////////////////////////////////////////////////// 12 | //////////////////////////////////////////////////////////////////////////////////////// 13 | 14 | 15 | var D2R = Math.PI / 180; 16 | var R2D = 180 / Math.PI; 17 | 18 | var Coord = function(lon,lat) { 19 | this.lon = lon; 20 | this.lat = lat; 21 | this.x = D2R * lon; 22 | this.y = D2R * lat; 23 | }; 24 | 25 | Coord.prototype.view = function () { 26 | return String(this.lon).slice(0, 4) + ',' + String(this.lat).slice(0, 4); 27 | }; 28 | 29 | Coord.prototype.antipode = function() { 30 | var anti_lat = -1 * this.lat; 31 | var anti_lon = (this.lon < 0) ? 180 + this.lon : (180 - this.lon) * -1; 32 | return new Coord(anti_lon, anti_lat); 33 | }; 34 | 35 | var LineString = function() { 36 | this.coords = []; 37 | this.length = 0; 38 | }; 39 | 40 | LineString.prototype.move_to = function(coord) { 41 | this.length++; 42 | this.coords.push(coord); 43 | }; 44 | 45 | var Arc = function(properties) { 46 | this.properties = properties || {}; 47 | this.geometries = []; 48 | }; 49 | 50 | Arc.prototype.json = function() { 51 | if (this.geometries.length <= 0) { 52 | return {'geometry': { 'type': 'LineString', 'coordinates': null }, 53 | 'type': 'Feature', 'properties': this.properties 54 | }; 55 | } else if (this.geometries.length == 1) { 56 | return {'geometry': { 'type': 'LineString', 'coordinates': this.geometries[0].coords }, 57 | 'type': 'Feature', 'properties': this.properties 58 | }; 59 | } else { 60 | var multiline = []; 61 | for (var i = 0; i < this.geometries.length; i++) { 62 | multiline.push(this.geometries[i].coords); 63 | } 64 | return {'geometry': { 'type': 'MultiLineString', 'coordinates': multiline }, 65 | 'type': 'Feature', 'properties': this.properties 66 | }; 67 | } 68 | }; 69 | 70 | // TODO - output proper multilinestring 71 | Arc.prototype.wkt = function() { 72 | var wkt_string = ''; 73 | var wkt = 'LINESTRING('; 74 | var collect = function(c) { wkt += c[0] + ' ' + c[1] + ','; }; 75 | for (var i = 0; i < this.geometries.length; i++) { 76 | if (this.geometries[i].coords.length === 0) { 77 | return 'LINESTRING(empty)'; 78 | } else { 79 | var coords = this.geometries[i].coords; 80 | coords.forEach(collect); 81 | wkt_string += wkt.substring(0, wkt.length - 1) + ')'; 82 | } 83 | } 84 | return wkt_string; 85 | }; 86 | 87 | /* 88 | * http://en.wikipedia.org/wiki/Great-circle_distance 89 | * 90 | */ 91 | var GreatCircle = function(start,end,properties) { 92 | if (!start || start.x === undefined || start.y === undefined) { 93 | throw new Error("GreatCircle constructor expects two args: start and end objects with x and y properties"); 94 | } 95 | if (!end || end.x === undefined || end.y === undefined) { 96 | throw new Error("GreatCircle constructor expects two args: start and end objects with x and y properties"); 97 | } 98 | this.start = new Coord(start.x,start.y); 99 | this.end = new Coord(end.x,end.y); 100 | this.properties = properties || {}; 101 | 102 | var w = this.start.x - this.end.x; 103 | var h = this.start.y - this.end.y; 104 | var z = Math.pow(Math.sin(h / 2.0), 2) + 105 | Math.cos(this.start.y) * 106 | Math.cos(this.end.y) * 107 | Math.pow(Math.sin(w / 2.0), 2); 108 | this.g = 2.0 * Math.asin(Math.sqrt(z)); 109 | 110 | if (this.g == Math.PI) { 111 | throw new Error('it appears ' + start.view() + ' and ' + end.view() + " are 'antipodal', e.g diametrically opposite, thus there is no single route but rather infinite"); 112 | } else if (isNaN(this.g)) { 113 | throw new Error('could not calculate great circle between ' + start + ' and ' + end); 114 | } 115 | }; 116 | 117 | /* 118 | * http://williams.best.vwh.net/avform.htm#Intermediate 119 | */ 120 | GreatCircle.prototype.interpolate = function(f) { 121 | var A = Math.sin((1 - f) * this.g) / Math.sin(this.g); 122 | var B = Math.sin(f * this.g) / Math.sin(this.g); 123 | var x = A * Math.cos(this.start.y) * Math.cos(this.start.x) + B * Math.cos(this.end.y) * Math.cos(this.end.x); 124 | var y = A * Math.cos(this.start.y) * Math.sin(this.start.x) + B * Math.cos(this.end.y) * Math.sin(this.end.x); 125 | var z = A * Math.sin(this.start.y) + B * Math.sin(this.end.y); 126 | var lat = R2D * Math.atan2(z, Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2))); 127 | var lon = R2D * Math.atan2(y, x); 128 | return [lon, lat]; 129 | }; 130 | 131 | 132 | 133 | /* 134 | * Generate points along the great circle 135 | */ 136 | GreatCircle.prototype.Arc = function(npoints,options) { 137 | var first_pass = []; 138 | if (!npoints || npoints <= 2) { 139 | first_pass.push([this.start.lon, this.start.lat]); 140 | first_pass.push([this.end.lon, this.end.lat]); 141 | } else { 142 | var delta = 1.0 / (npoints - 1); 143 | for (var i = 0; i < npoints; ++i) { 144 | var step = delta * i; 145 | var pair = this.interpolate(step); 146 | first_pass.push(pair); 147 | } 148 | } 149 | /* partial port of dateline handling from: 150 | gdal/ogr/ogrgeometryfactory.cpp 151 | 152 | TODO - does not handle all wrapping scenarios yet 153 | */ 154 | var bHasBigDiff = false; 155 | var dfMaxSmallDiffLong = 0; 156 | // from http://www.gdal.org/ogr2ogr.html 157 | // -datelineoffset: 158 | // (starting with GDAL 1.10) offset from dateline in degrees (default long. = +/- 10deg, geometries within 170deg to -170deg will be splited) 159 | var dfDateLineOffset = options && options.offset ? options.offset : 10; 160 | var dfLeftBorderX = 180 - dfDateLineOffset; 161 | var dfRightBorderX = -180 + dfDateLineOffset; 162 | var dfDiffSpace = 360 - dfDateLineOffset; 163 | 164 | // https://github.com/OSGeo/gdal/blob/7bfb9c452a59aac958bff0c8386b891edf8154ca/gdal/ogr/ogrgeometryfactory.cpp#L2342 165 | for (var j = 1; j < first_pass.length; ++j) { 166 | var dfPrevX = first_pass[j-1][0]; 167 | var dfX = first_pass[j][0]; 168 | var dfDiffLong = Math.abs(dfX - dfPrevX); 169 | if (dfDiffLong > dfDiffSpace && 170 | ((dfX > dfLeftBorderX && dfPrevX < dfRightBorderX) || (dfPrevX > dfLeftBorderX && dfX < dfRightBorderX))) { 171 | bHasBigDiff = true; 172 | } else if (dfDiffLong > dfMaxSmallDiffLong) { 173 | dfMaxSmallDiffLong = dfDiffLong; 174 | } 175 | } 176 | 177 | var poMulti = []; 178 | if (bHasBigDiff && dfMaxSmallDiffLong < dfDateLineOffset) { 179 | var poNewLS = []; 180 | poMulti.push(poNewLS); 181 | for (var k = 0; k < first_pass.length; ++k) { 182 | var dfX0 = parseFloat(first_pass[k][0]); 183 | if (k > 0 && Math.abs(dfX0 - first_pass[k-1][0]) > dfDiffSpace) { 184 | var dfX1 = parseFloat(first_pass[k-1][0]); 185 | var dfY1 = parseFloat(first_pass[k-1][1]); 186 | var dfX2 = parseFloat(first_pass[k][0]); 187 | var dfY2 = parseFloat(first_pass[k][1]); 188 | if (dfX1 > -180 && dfX1 < dfRightBorderX && dfX2 == 180 && 189 | k+1 < first_pass.length && 190 | first_pass[k-1][0] > -180 && first_pass[k-1][0] < dfRightBorderX) 191 | { 192 | poNewLS.push([-180, first_pass[k][1]]); 193 | k++; 194 | poNewLS.push([first_pass[k][0], first_pass[k][1]]); 195 | continue; 196 | } else if (dfX1 > dfLeftBorderX && dfX1 < 180 && dfX2 == -180 && 197 | k+1 < first_pass.length && 198 | first_pass[k-1][0] > dfLeftBorderX && first_pass[k-1][0] < 180) 199 | { 200 | poNewLS.push([180, first_pass[k][1]]); 201 | k++; 202 | poNewLS.push([first_pass[k][0], first_pass[k][1]]); 203 | continue; 204 | } 205 | 206 | if (dfX1 < dfRightBorderX && dfX2 > dfLeftBorderX) 207 | { 208 | // swap dfX1, dfX2 209 | var tmpX = dfX1; 210 | dfX1 = dfX2; 211 | dfX2 = tmpX; 212 | // swap dfY1, dfY2 213 | var tmpY = dfY1; 214 | dfY1 = dfY2; 215 | dfY2 = tmpY; 216 | } 217 | if (dfX1 > dfLeftBorderX && dfX2 < dfRightBorderX) { 218 | dfX2 += 360; 219 | } 220 | 221 | if (dfX1 <= 180 && dfX2 >= 180 && dfX1 < dfX2) 222 | { 223 | var dfRatio = (180 - dfX1) / (dfX2 - dfX1); 224 | var dfY = dfRatio * dfY2 + (1 - dfRatio) * dfY1; 225 | poNewLS.push([first_pass[k-1][0] > dfLeftBorderX ? 180 : -180, dfY]); 226 | poNewLS = []; 227 | poNewLS.push([first_pass[k-1][0] > dfLeftBorderX ? -180 : 180, dfY]); 228 | poMulti.push(poNewLS); 229 | } 230 | else 231 | { 232 | poNewLS = []; 233 | poMulti.push(poNewLS); 234 | } 235 | poNewLS.push([dfX0, first_pass[k][1]]); 236 | } else { 237 | poNewLS.push([first_pass[k][0], first_pass[k][1]]); 238 | } 239 | } 240 | } else { 241 | // add normally 242 | var poNewLS0 = []; 243 | poMulti.push(poNewLS0); 244 | for (var l = 0; l < first_pass.length; ++l) { 245 | poNewLS0.push([first_pass[l][0],first_pass[l][1]]); 246 | } 247 | } 248 | 249 | var arc = new Arc(this.properties); 250 | for (var m = 0; m < poMulti.length; ++m) { 251 | var line = new LineString(); 252 | arc.geometries.push(line); 253 | var points = poMulti[m]; 254 | for (var j0 = 0; j0 < points.length; ++j0) { 255 | line.move_to(points[j0]); 256 | } 257 | } 258 | return arc; 259 | }; 260 | -------------------------------------------------------------------------------- /code/src/lava/bingmap/geoService.ts: -------------------------------------------------------------------------------- 1 | import { copy } from '../type'; 2 | import { ILocation } from './converter'; 3 | import { Func, StringMap, keys } from '../type'; 4 | import { jsonp } from './jsonp'; 5 | 6 | 7 | var _injected = {} as StringMap; 8 | 9 | export function inject(locs: StringMap, reset = false): void { 10 | locs = locs || {}; 11 | if (reset) { 12 | _injected = locs; 13 | return; 14 | } 15 | for (var key of keys(locs)) { 16 | var loc = locs[key]; 17 | if (loc) { 18 | _injected[key] = loc; 19 | } 20 | else { 21 | delete _injected[key]; 22 | } 23 | } 24 | } 25 | 26 | export function remove(where: Func): void { 27 | for (var key of keys(_injected)) { 28 | if (where(_injected[key])) { 29 | delete _injected[key]; 30 | } 31 | } 32 | } 33 | 34 | export function latitude(addr: string): number { 35 | var loc = query(addr); 36 | if (loc) { 37 | return loc.latitude; 38 | } 39 | else { 40 | return null; 41 | } 42 | } 43 | 44 | export function longitude(addr: string): number{ 45 | var loc = query(addr); 46 | if (loc) { 47 | return loc.longitude; 48 | } 49 | else { 50 | return null; 51 | } 52 | } 53 | 54 | export function query(addr: string): ILocation; 55 | export function query(addr: string, then: Func): void; 56 | export function query(addr: string, then?: Func): any { 57 | if (then) { 58 | var loc = _injected[addr]; 59 | if (loc) { 60 | loc.address = addr; 61 | then(loc); 62 | } 63 | else if (addr in _initCache) { 64 | loc = _initCache[addr]; 65 | loc.address = addr; 66 | then(loc); 67 | } 68 | else { 69 | geocodeCore(new GeocodeQuery(addr), then); 70 | } 71 | return undefined; 72 | } 73 | else { 74 | if (_injected[addr]) { 75 | return _injected[addr]; 76 | } 77 | else if (_initCache[addr]) { 78 | return _initCache[addr]; 79 | } 80 | var rec = geocodeCache[addr.toLowerCase()]; 81 | if (rec) { 82 | rec.query.incrementCacheHit(); 83 | return rec.coordinate; 84 | } 85 | return null; 86 | } 87 | } 88 | 89 | var _initCache = {} as StringMap; 90 | export function initCache(locs: StringMap) { 91 | _initCache = copy(locs); 92 | } 93 | 94 | export var settings = { 95 | // Maximum Bing requests at once. The Bing have limit how many request at once you can do per socket. 96 | MaxBingRequest: 6, 97 | 98 | // Maximum cache size of cached geocode data. 99 | MaxCacheSize: 3000, 100 | 101 | // Maximum cache overflow of cached geocode data to kick the cache reducing. 102 | MaxCacheSizeOverflow: 1000, 103 | 104 | // Bing Keys and URL 105 | BingKey: "Your key here", 106 | BingURL: "https://dev.virtualearth.net/REST/v1/Locations?", 107 | BingUrlGeodata: "https://platform.bing.com/geo/spatial/v1/public/Geodata?", 108 | }; 109 | 110 | //private 111 | interface IGeocodeQuery { 112 | query: string; 113 | longitude?: number; 114 | latitude?: number; 115 | } 116 | 117 | interface IGeocodeCache { 118 | query: GeocodeQuery; 119 | coordinate: ILocation; 120 | } 121 | 122 | interface IGeocodeQueueItem { 123 | query: GeocodeQuery; 124 | then: (v: ILocation) => void; 125 | } 126 | 127 | var geocodeCache: { [key: string]: IGeocodeCache; }; 128 | var geocodeQueue: IGeocodeQueueItem[]; 129 | var activeRequests; 130 | 131 | class GeocodeQuery implements IGeocodeQuery { 132 | public query : string; 133 | public key : string; 134 | private _cacheHits: number; 135 | 136 | constructor(query: string = "") { 137 | this.query = query; 138 | this.key = this.query.toLowerCase(); 139 | this._cacheHits = 0; 140 | } 141 | 142 | public incrementCacheHit(): void { 143 | this._cacheHits++; 144 | } 145 | 146 | public getCacheHits(): number { 147 | return this._cacheHits; 148 | } 149 | 150 | public getBingUrl(): string { 151 | var url = settings.BingURL + "key=" + settings.BingKey; 152 | if (isNaN(+this.query)) { 153 | url += "&q=" + encodeURIComponent(this.query); 154 | } 155 | else { 156 | url += "&postalCode=" + this.query; 157 | } 158 | 159 | var cultureName = navigator['userLanguage'] || navigator["language"]; 160 | if (cultureName) { 161 | url += "&c=" + cultureName; 162 | } 163 | url += "&maxRes=20"; 164 | return url; 165 | } 166 | } 167 | 168 | function findInCache(query: GeocodeQuery): ILocation { 169 | var pair = geocodeCache[query.key]; 170 | if (pair) { 171 | pair.query.incrementCacheHit(); 172 | return pair.coordinate; 173 | } 174 | return undefined; 175 | } 176 | 177 | function cacheQuery(query: GeocodeQuery, coordinate: ILocation): void { 178 | var keys = Object.keys(geocodeCache); 179 | var cacheSize = keys.length; 180 | 181 | if (Object.keys(geocodeCache).length > (settings.MaxCacheSize + settings.MaxCacheSizeOverflow)) { 182 | 183 | var sorted = keys.sort((a: string, b: string) => { 184 | var ca = geocodeCache[a].query.getCacheHits(); 185 | var cb = geocodeCache[b].query.getCacheHits(); 186 | return ca < cb ? -1 : (ca > cb ? 1 : 0); 187 | }); 188 | 189 | for (var i = 0; i < (cacheSize - settings.MaxCacheSize); i++) { 190 | delete geocodeCache[sorted[i]]; 191 | } 192 | } 193 | 194 | geocodeCache[query.key] = { query: query, coordinate: coordinate }; 195 | } 196 | 197 | function geocodeCore(geocodeQuery: GeocodeQuery, then: (v: ILocation) => void): void { 198 | var result = findInCache(geocodeQuery); 199 | if (result) { 200 | result.address = geocodeQuery.query; 201 | then(result); 202 | } else { 203 | geocodeQueue.push({ query: geocodeQuery, then: then }); 204 | releaseQuota(); 205 | } 206 | } 207 | 208 | // export function batch(queries: string[]) 209 | 210 | export function getCacheSize(): number { 211 | return Object.keys(geocodeCache).length; 212 | } 213 | 214 | function releaseQuota(decrement: number = 0) { 215 | activeRequests -= decrement; 216 | while (activeRequests < settings.MaxBingRequest) { 217 | if (geocodeQueue.length == 0) { 218 | break; 219 | } 220 | activeRequests++; 221 | makeRequest(geocodeQueue.shift()); 222 | } 223 | } 224 | 225 | // var debugCache: { [key: string]: ILocation }; 226 | function makeRequest(item: IGeocodeQueueItem) { 227 | // Check again if we already got the coordinate; 228 | var result = findInCache(item.query); 229 | if (result) { 230 | result.address = item.query.query; 231 | setTimeout(() => releaseQuota(1)); 232 | item.then(result); 233 | return; 234 | } 235 | 236 | // if (!debugCache) { 237 | // debugCache = {}; 238 | // // let coords = debugData.locs; 239 | // // let names = debugData.names; 240 | // for (let i = 0; i < names.length; i++) { 241 | // let key = names[i].toLowerCase(); 242 | // debugCache[key] = { 243 | // latitude: coords[i * 2], 244 | // longitude: coords[i * 2 + 1], 245 | // type: 'test', 246 | // name: item.query.query 247 | // }; 248 | // } 249 | // } 250 | // if (debugCache[item.query.key]) { 251 | // setTimeout(() => { 252 | // completeRequest(item, null, debugCache[item.query.key]); 253 | // }, 80); 254 | // return; 255 | // } 256 | 257 | // Unfortunately the Bing service doesn't support CORS, only jsonp. 258 | // This issue must be raised and revised. 259 | // VSTS: 1396088 - Tracking: Ask: Bing geocoding to support CORS 260 | var url = item.query.getBingUrl(); 261 | 262 | jsonp.get(url, data => { 263 | if (!data || !data.resourceSets || data.resourceSets.length < 1) { 264 | completeRequest(item, ERROR_EMPTY, null); 265 | return; 266 | } 267 | var error = null as Error, result=null as ILocation; 268 | try { 269 | var resources = data.resourceSets[0].resources; 270 | if (Array.isArray(resources) && resources.length > 0) { 271 | var index = getBestResultIndex(resources, item.query); 272 | var pointData = resources[index].point.coordinates; 273 | var result = { 274 | latitude: +pointData[0], 275 | longitude: +pointData[1], 276 | type: resources[index].entityType, 277 | name: resources[index].name 278 | } as ILocation; 279 | } 280 | else { 281 | error = ERROR_EMPTY; 282 | } 283 | } 284 | catch (e) { 285 | error = e; 286 | } 287 | completeRequest(item, error, result); 288 | }); 289 | } 290 | 291 | var ERROR_EMPTY = new Error("Geocode result is empty."); 292 | var dequeueTimeoutId; 293 | 294 | function completeRequest(item: IGeocodeQueueItem, error: Error, coordinate: ILocation = null) { 295 | dequeueTimeoutId = setTimeout(() => releaseQuota(1), 0); 296 | if (error) { 297 | item.then(undefined); 298 | } 299 | else { 300 | cacheQuery(item.query, coordinate); 301 | coordinate.address = item.query.query; 302 | item.then(coordinate); 303 | } 304 | } 305 | 306 | function getBestResultIndex(resources: any[], query: GeocodeQuery) { 307 | return 0; 308 | } 309 | 310 | function reset(): void { 311 | geocodeCache = {}; 312 | geocodeQueue = []; 313 | activeRequests = 0; 314 | clearTimeout(dequeueTimeoutId); 315 | dequeueTimeoutId = null; 316 | } 317 | 318 | function captureBingErrors() { 319 | try { 320 | var lastError: Function = window.window.onerror || (() => { }); 321 | window.window.onerror = (msg: Object, url: string, line: number, column?: number, error?: any) => { 322 | if (url.indexOf(settings.BingURL) != -1 || url.indexOf(settings.BingUrlGeodata) != -1) { 323 | return false; 324 | } 325 | lastError(msg, url, line, column, error); 326 | }; 327 | } 328 | catch(error) { 329 | console.log(error); 330 | } 331 | } 332 | 333 | reset(); 334 | captureBingErrors(); 335 | -------------------------------------------------------------------------------- /code/src/lava/flowmap/legend.ts: -------------------------------------------------------------------------------- 1 | import { $state } from './app'; 2 | import { Banner } from './banner'; 3 | import { StringMap, keys, values, IPoint, Func } from '../type'; 4 | import * as util from './util'; 5 | import { scaleLinear } from 'd3-scale'; 6 | import { ISelex, selex } from '../d3'; 7 | 8 | interface DistinctColor { 9 | center?: IPoint; 10 | label: string; 11 | color: string; 12 | } 13 | 14 | let msgs = { 15 | relocate: 'Drag/Drop to manually change geo-locations. Turn off "Relocate" when done.' 16 | } 17 | 18 | export class Legend { 19 | 20 | private _svg: ISelex; 21 | private _banner: Banner<'issue'>; 22 | 23 | constructor(div: ISelex) { 24 | this._banner = new Banner<'issue'>(selex('#warn'), [238, 130, 124]) 25 | .key(k => k) 26 | .border(() => $state.border) 27 | .content(k => this._warn()) 28 | .anchor(k => { 29 | if ($state.config.legend.position === 'top') { 30 | return { x: $state.border.width / 2, y: this.height() }; 31 | } 32 | else { 33 | return { x: $state.border.width / 2, y: $state.border.height - this.height() }; 34 | } 35 | }); 36 | this._svg = div.append('svg') 37 | .sty.cursor('default').sty.pointer_events('visiblePainted'); 38 | this._svg.sty.cursor('default').sty.position('absolute'); 39 | this._svg.append('rect').att.id('mask').att.fill('white') 40 | .att.translate(0, -1).att.width('100%').att.height('100%') 41 | .on('mouseover', _ => { 42 | if (values($state.issues).some(v => v.negative || v.selflink || v.unlocate)) { 43 | const position = $state && $state.config && $state.config.legend.position !== 'top' ? 'top' : 'bottom'; 44 | this._banner.add('issue', position); 45 | } 46 | }) 47 | .on('mouseout', _ => this._banner.clear()); 48 | this._svg.append('g').att.id("color"); 49 | this._svg.append('g').att.id("scale"); 50 | this._svg.append('text').att.id('info').att.x(2); 51 | this._svg.append('defs').append('linearGradient').att.id('grad') 52 | .selectAll('stop').data([0, 0.5, 1]).enter().append('stop') 53 | .att.offset(i => (i * 100) + '%').att.stop_color(i => '#FF0000'); 54 | } 55 | 56 | private _textY() { 57 | return (+$state.config.legend.fontSize) + 2; 58 | } 59 | 60 | private _warn() { 61 | const issues = values($state.issues); 62 | const div = selex(document.createElement('div')).att.class('war'); 63 | div.append('div').att.class('header').text('The visualization is incomplete due to:'); 64 | const section = (arr: string[], name: string) => { 65 | if (arr.length === 0) { 66 | return; 67 | } 68 | let content = ''; 69 | if (arr.length > 8) { 70 | var rest = `...(${arr.length - 8} more)`; 71 | content = arr.slice(0, 8).concat(rest).join(', '); 72 | } 73 | else { 74 | content = arr.join(', '); 75 | } 76 | let row = div.append('div').att.class('row'); 77 | row.append('div').att.class('sect').text(name); 78 | row.append('div').att.class('value').text(content); 79 | }; 80 | section(issues.filter(v => v.unlocate).map(v => v.unlocate), 'Unlocatable:'); 81 | section(issues.filter(v => v.selflink).map(v => v.selflink), 'Self-link:'); 82 | section(issues.filter(v => v.negative).map(v => v.negative), 'Negative value:'); 83 | return div.node(); 84 | } 85 | 86 | height(): number { 87 | return +$state.config.legend.fontSize * 1.5 + 4; 88 | } 89 | 90 | private _colorWidth = 0; 91 | private _scaleWidth = 0; 92 | resize() { 93 | const { show, position } = $state.config.legend; 94 | const height = $state.mapctl.map.getHeight(); 95 | const width = $state.mapctl.map.getWidth(); 96 | const legHeight = show ? this.height() : 0; 97 | const top = position === 'top' ? null : (height - legHeight + 2) + 'px'; 98 | this._svg.sty.margin_top(top).sty.display(show ? null : 'none'); 99 | this._resize(width); 100 | } 101 | 102 | 103 | private _resize(width: number) { 104 | this._svg.att.width(width).att.height(this.height()).sty.font_size($state.config.legend.fontSize + 'px'); 105 | let space = width - this._colorWidth; 106 | let bias = space < this._scaleWidth ? this._colorWidth : width - this._scaleWidth; 107 | this._svg.select('#scale').att.translate(bias, 0); 108 | } 109 | 110 | private _info: string; 111 | info(v: string): void { 112 | // throw 'show info'; 113 | this._info = v; 114 | if (!v && $state.config.advance.relocate) { 115 | this._info = msgs.relocate; 116 | } 117 | this._updateDisplay(); 118 | this._svg.select('#info').att.y(this._textY()).text(this._info); 119 | } 120 | 121 | public clear() { 122 | this._svg.select('#scale').selectAll('*').remove(); 123 | this._svg.select('#color').selectAll('*').remove(); 124 | this._svg.select('#info').text(''); 125 | } 126 | 127 | d3(key: 'info') { 128 | return this._svg.select('#info'); 129 | } 130 | 131 | public recolor(color: { domain: number[], range: string[] } | { distinct: StringMap }) { 132 | this._colorWidth = 0; 133 | const root = this._svg.select('#color'); 134 | root.selectAll('*').remove(); 135 | if (!$state.config.legend.color) { 136 | return; 137 | } 138 | if ('distinct' in color) { 139 | const distinct = color.distinct; 140 | const colors = {} as StringMap; 141 | for (let color of keys(distinct)) { 142 | colors[color] = { color, label: distinct[color] }; 143 | } 144 | const pos = $state.config.legend.position === 'top' ? 'bottom' : 'top'; 145 | let groups = root.selectAll('g').data(values(colors)).enter().append('g'); 146 | 147 | let r = (+$state.config.legend.fontSize) * 0.7 / 2, y = this._textY(), bias = 0; 148 | groups.append('circle') 149 | .att.cx(r + 3).att.cy(y - r).att.r(r).att.stroke_width(1) 150 | .att.fill(info => info.color).att.stroke(info => info.color); 151 | groups.append('text').att.x(2 * r + 4).att.y(y) 152 | .text(info => info.label).att.fill(null); 153 | 154 | let centerY = pos === 'top' ? 2 : this._textY(); 155 | groups.each(function (info, i) { 156 | const item = selex(this).att.translate(bias, 0); 157 | const box = item.node().getBBox(); 158 | info.center = { x: bias + box.x + box.width / 2, y: centerY }; 159 | bias += box.x + box.width + 10;//10 is the gap between color items 160 | }); 161 | this._colorWidth = bias; 162 | } 163 | else { 164 | const { domain, range } = color; 165 | let conv = scaleLinear().domain(domain).range(range); 166 | if (domain.some(v => util.bad(v))) { 167 | conv = scaleLinear().domain([0, 1]).range(range); 168 | this._svg.select('#grad').selectAll('stop').att.stop_color(v => conv(+v)); 169 | } 170 | else { 171 | this._svg.select('#grad').selectAll('stop') 172 | .att.stop_color(v => { 173 | if (v === 0) 174 | return conv(domain[0]); 175 | else if (v === 1) 176 | return conv(domain[1]); 177 | else 178 | return conv(domain[0] / 2 + domain[1] / 2); 179 | }); 180 | } 181 | const fontsize = +$state.config.legend.fontSize, height = this.height(); 182 | const w = fontsize * 15, h = fontsize / 2; 183 | root.append('rect').att.width(w).att.height(h).att.x(0).att.y(height - 2 - h) 184 | .sty.fill('url(#grad)'); 185 | const labels = util.nice(domain); 186 | root.append('text').text(labels[0]).att.x(0).att.y(fontsize + 1).att.text_anchor('start'); 187 | root.append('text').text(labels[1]).att.x(w).att.y(fontsize + 1).att.text_anchor('end'); 188 | this._colorWidth = root.node().getBBox().width + 2; 189 | root.att.translate(2, 0); 190 | } 191 | } 192 | 193 | public rewidth(width: { invert: Func, scale: Func, dmax: number } | { distinct: StringMap }) { 194 | this._scaleWidth = 0; 195 | this._svg.select('#scale').selectAll('*').remove(); 196 | if (!$state.config.legend.width) {//hiden 197 | return; 198 | } 199 | else if ('invert' in width) { 200 | const { scale, invert, dmax } = width, root = this._svg.select('#scale'), cap = this.height() - 2; 201 | const v1 = Math.max(0, invert(1)), vhalf = invert(cap / 2), vcap = invert(cap), linear = scaleLinear(); 202 | if (vcap <= 0 || dmax < v1) { 203 | return; 204 | } 205 | else if (dmax < vhalf) { 206 | linear.domain([v1, vhalf]).range([1, cap / 2]); 207 | } 208 | else if (dmax < vcap) { 209 | linear.domain([v1, dmax]).range([1, scale(dmax)]); 210 | } 211 | else { 212 | linear.domain([v1, vcap]).range([1, cap]); 213 | } 214 | let ticks = null as number[], fmt = null as Func; 215 | for (let cnt = 4; cnt < 10; cnt++) { 216 | let vals = linear.ticks(cnt); 217 | if (vals[0] === 0) { 218 | vals.shift(); 219 | } 220 | if (vals.length >= 3) { 221 | fmt = linear.tickFormat(cnt, 's'); 222 | ticks = vals.slice(0, 3); 223 | break; 224 | } 225 | } 226 | if (!ticks) { 227 | return; 228 | } 229 | const bars = root.selectAll('.bar').data(ticks).enter().append('g').att.class('bar'); 230 | let barWidth = 20; 231 | bars.append('rect').sty.fill('#AAA'); 232 | bars.append('text').att.class('mark').sty.text_anchor('middle') 233 | .att.y(this._textY()).text(v => fmt(v)) 234 | .each(function () { barWidth = Math.max((this as any).getBBox().width + 2, barWidth); }); 235 | 236 | bars.selectAll('rect').att.x(0).att.y(d => cap - scale(+d)) 237 | .att.width(barWidth).att.height(d => scale(+d)); 238 | bars.selectAll('text').att.x(barWidth / 2); 239 | bars.att.translate((_, i) => i * (barWidth + 10), () => 0); 240 | this._scaleWidth = (barWidth + 10) * ticks.length - 10; 241 | } 242 | else { 243 | const distinct = width.distinct; 244 | const root = this._svg.select('#scale'); 245 | const fsize = +$state.config.legend.fontSize; 246 | const wids = keys(distinct).map(w => +w); 247 | const middle = this._textY() - fsize * 0.7 / 2, length = 1.5 * fsize; 248 | const groups = root.selectAll('g').data(wids).enter().append('g'); 249 | groups.append('rect').att.y(w => middle - w / 2) 250 | .att.width(length).att.height(w => w).att.fill('#555'); 251 | let bias = 0; 252 | groups.append('text').att.x(length + 2).att.y(this._textY()).text(w => distinct[w]); 253 | groups.each(function () { 254 | const item = selex(this); 255 | item.att.translate(bias, 0); 256 | bias += item.node().getBBox().width + 10;//10 is the gap 257 | }); 258 | this._scaleWidth = bias; 259 | } 260 | } 261 | 262 | private _updateDisplay(): void { 263 | let { color: showColor, width: showWidth } = $state.config.legend; 264 | if (this._info) { 265 | showColor = showWidth = false; 266 | } 267 | this._svg.select('#color').sty.display(showColor ? null : 'none'); 268 | this._svg.select('#scale').sty.display(showWidth ? null : 'none'); 269 | this._svg.select('#info').sty.display(this._info ? null : 'none'); 270 | } 271 | } -------------------------------------------------------------------------------- /code/src/pbi/Format.ts: -------------------------------------------------------------------------------- 1 | import * as deepmerge from 'deepmerge'; 2 | import powerbi from 'powerbi-visuals-api'; 3 | import { StringMap, Func, partial, copy } from '../lava/type'; 4 | import { Persist } from './Persist'; 5 | import { Category } from './Category'; 6 | import { Context } from './Context'; 7 | import * as deepequal from 'fast-deep-equal'; 8 | import * as clone from 'clone'; 9 | type Instance = powerbi.VisualObjectInstance; 10 | 11 | export interface Binding { 12 | readonly role: R, 13 | readonly toggle: keyof O | true, 14 | readonly autofill?: keyof O, 15 | readonly pname: keyof O, 16 | readonly fmt: FormatManager 17 | } 18 | 19 | type Mark = { [P in keyof T]?: V; } 20 | 21 | export type Config = T extends { solid: { color: string } } ? string : T; 22 | 23 | export type FormatInstance = { row: number, value?: any, name: string, key?: string, auto?: any }; 24 | 25 | export type Value = Func>; 26 | 27 | const __auto = {} as StringMap; 28 | const __deft = {} as StringMap; 29 | const __full = {} as StringMap; 30 | 31 | function meta(fmt: FormatManager): Readonly; 32 | function meta(fmt: FormatManager, pname: P): O[P]; 33 | function meta(fmt: FormatManager, pnames: (keyof O)[]): Partial; 34 | function meta(fmt: FormatManager, p?: any): any { 35 | if (p === undefined || p === null) { 36 | return copy(__full[fmt.oname]); 37 | } 38 | if (typeof p === 'string') { 39 | return __full[fmt.oname][p]; 40 | } 41 | else { 42 | return partial(__full[fmt.oname], p); 43 | } 44 | } 45 | 46 | function metaItem(fmt: FormatManager, pname: P): Func { 47 | const dft = meta(fmt, pname); 48 | if (!fmt.binding(pname)) { 49 | return () => dft; 50 | } 51 | const { toggle, role } = fmt.binding(pname); 52 | if (!__ctx.cat(role) || (toggle !== true && !meta(fmt, toggle))) { 53 | return _ => dft; 54 | } 55 | const special = fmt.special(pname), key = __ctx.cat(role).key; 56 | const autofill = __auto[fmt.oname][pname] ? __auto[fmt.oname][pname]() : null; 57 | return v => { 58 | const id = typeof v === 'number' ? key(v) : v; 59 | if (id === undefined || id === null) { 60 | return dft; 61 | } 62 | else if (id in special) { 63 | return special[id]; 64 | } 65 | else if (autofill) { 66 | return autofill(id); 67 | } 68 | else { 69 | return dft; 70 | } 71 | } 72 | } 73 | 74 | let __ctx = null as Context; 75 | 76 | export class FormatManager { 77 | persist

(meta: P, value: O[P]): void { 78 | __ctx.persist(this.oname, meta, value); 79 | } 80 | 81 | public item

(pname: P): Func> { 82 | const func = metaItem(this, pname); 83 | return v => this._bare(func(v)); 84 | } 85 | 86 | public readonly oname: string; 87 | private _default: O; 88 | private _meta = null as Partial; 89 | private _binds = {} as Mark>; 90 | private _persist = null as Persist>; 91 | 92 | private _dirty = null as Mark; 93 | 94 | 95 | constructor(oname: string, deft: O, ctx: Context) { 96 | this.oname = oname; 97 | this._default = deft; 98 | __ctx = ctx; 99 | __auto[oname] = {}; 100 | __deft[oname] = {}; 101 | __full[oname] = {}; 102 | } 103 | 104 | public binding

(pname: P): Binding { 105 | return this._binds[pname]; 106 | } 107 | 108 | public config

(pname: P): Config { 109 | return this._bare(this._full[pname]); 110 | } 111 | 112 | public special

(pname: P): Readonly> { 113 | const values = this._persist && this._persist.value(); 114 | return (values && values[pname as string]) || {}; 115 | } 116 | 117 | public dumper(): FormatDumper { 118 | return new FormatDumper(this); 119 | } 120 | 121 | //only meta boolean dirty will return 'on'/'off', otherwise return 122 | dirty(pnames: (keyof O)[]): boolean; 123 | dirty(pname: keyof O): 'on' | 'off' | false; 124 | dirty(): boolean; 125 | public dirty(arr?: (keyof O)[] | keyof O): 'on' | 'off' | boolean { 126 | if (arr === undefined) { 127 | return Object.keys(this._dirty).length !== 0; 128 | } 129 | if (typeof arr === 'string') { 130 | if (this._binds[arr]) { 131 | return this._dirty[arr] ? true : false; 132 | } 133 | else if (this._dirty[arr]) { 134 | const value = this.config(arr) as any; 135 | return value === true ? 'on' : (value === false ? 'off' : true); 136 | } 137 | else { 138 | return false; 139 | } 140 | } 141 | else { 142 | for (const pname of arr as (keyof O)[]) { 143 | if (this.dirty(pname)) { 144 | return true; 145 | } 146 | } 147 | return false; 148 | } 149 | } 150 | 151 | private _bare(v: T): Config { 152 | if (v && typeof v === 'object' && 'solid' in v) { 153 | return v['solid']['color']; 154 | } 155 | return v as Config; 156 | } 157 | 158 | private get _auto(): { [key in keyof O]: Func> } { return __auto[this.oname]; } 159 | private get _deft(): { [key in keyof O]: Func } { return __deft[this.oname]; } 160 | private get _full(): Readonly { return __full[this.oname]; } 161 | 162 | public autofill

(pname: P, auto: Func): void { 163 | this._deft[pname] = auto; 164 | } 165 | 166 | public bind

(role: R, pname: P, toggle: keyof O | true, autofill: keyof O, auto: O[P] | Func): void; 167 | public bind

(role: R, pname: P, toggle: keyof O | true): void; 168 | public bind

(role: R, pname: P, toggle: keyof O | true, autofill?: keyof O, auto?: O[P] | Func): void { 169 | if (!this._persist) { 170 | //the key 'persist' is hardcoded, capabilities.json mush have it as well 171 | this._persist = new Persist>(this.oname, 'persist'); 172 | } 173 | if (autofill) { 174 | this._auto[pname] = () => { 175 | let deft = meta(this, pname); 176 | if (deft === null) { 177 | deft = this._deft[pname] ? this._deft[pname]() : deft; 178 | } 179 | const { role, toggle } = this._binds[pname]; 180 | if (!__ctx.cat(role) || (toggle !== true && !meta(this, toggle))) { 181 | return v => deft; 182 | } 183 | const key = __ctx.cat(role).key; 184 | if (!meta(this, autofill)) { 185 | return v => deft; 186 | } 187 | else if (typeof auto === 'function') { 188 | return v => (auto as Func)(typeof v === 'number' ? key(v) : v); 189 | } 190 | else { 191 | return v => auto; 192 | } 193 | }; 194 | this._binds[pname] = { role, toggle, autofill, pname, fmt: this } as Binding; 195 | } 196 | else { 197 | this._binds[pname] = { role, toggle, pname, fmt: this } as Binding; 198 | } 199 | } 200 | 201 | //undefined if not existing 202 | private _collect(cat: Category, pname: string): StringMap { 203 | const result = {} as StringMap; 204 | if (!cat || !cat.column) { 205 | return undefined; 206 | } 207 | const columnObjs = cat.column.objects || {}; 208 | for (const row in columnObjs) { 209 | const obj = columnObjs[row] && columnObjs[row][this.oname]; 210 | if (obj && pname in obj) { 211 | result[cat.key(+row)] = obj[pname]; 212 | } 213 | } 214 | return Object.keys(result).length ? result : undefined; 215 | } 216 | 217 | private _patch(format: Partial): O { 218 | const result = deepmerge(this._default, format || {}); 219 | for (const key in result) { 220 | if (result[key] === null && key in this._deft) { 221 | result[key] = this._deft[key](); 222 | } 223 | } 224 | return result; 225 | } 226 | 227 | public update(format: Partial): Readonly { 228 | const newFmt = __full[this.oname] = this._patch(format), oldFmt = this._patch(this._meta); 229 | this._meta = format; 230 | const dirty = this._dirty = {} as Mark; 231 | for (const pname in this._default) { 232 | if (this._bare(newFmt[pname]) !== this._bare(oldFmt[pname])) { 233 | dirty[pname] = true; 234 | } 235 | } 236 | let itemChanged = false; 237 | const persist = clone(this._persist && this._persist.value() || {}); 238 | for (const pname in this._binds) { 239 | const binding = this._binds[pname]; 240 | const special = this._collect(__ctx.cat(binding.role), pname); 241 | persist[pname] = persist[pname] || {}; 242 | if (!special && !format) { 243 | if (Object.keys(persist[pname]).length) { 244 | persist[pname] = {}; 245 | itemChanged = dirty[pname] = true; 246 | } 247 | } 248 | for (const k in special || {}) { 249 | if (!deepequal(persist[pname][k], special[k])) { 250 | itemChanged = dirty[pname] = true; 251 | persist[pname][k] = special[k]; 252 | } 253 | } 254 | } 255 | if (itemChanged) { 256 | const dump = {} as StringMap; 257 | for (const pname in this._binds) { 258 | if (Object.keys(persist[pname]).length) { 259 | dump[pname] = persist[pname]; 260 | } 261 | } 262 | if (Object.keys(dump).length) { 263 | this._persist.write(dump, 10); 264 | } 265 | else { 266 | this._persist.write(null, 10); 267 | } 268 | } 269 | return this._full; 270 | } 271 | } 272 | 273 | export class FormatDumper { 274 | private _fmt: FormatManager; 275 | private _dump = [] as Instance[]; 276 | constructor(fmt: FormatManager) { 277 | this._fmt = fmt; 278 | } 279 | 280 | 281 | public get default() { 282 | return [{ 283 | objectName: this._fmt.oname, 284 | properties: __full[this._fmt.oname], 285 | selector: null 286 | }]; 287 | } 288 | 289 | public metas(prefer: Partial): this; 290 | public metas(toggle: keyof T | boolean, fields: (keyof T)[], prefer?: Partial): this; 291 | public metas(fields: (keyof T)[], prefer?: Partial): this; 292 | public metas(a: any, b?: any, c?: any): this { 293 | if (a === undefined || a === null) {//() 294 | this._metas(undefined, undefined); 295 | } 296 | else if (typeof a === 'object' && !Array.isArray(a)) {//prefer: Partial 297 | this._metas(undefined, a); 298 | } 299 | if (typeof a === 'boolean') {//toggle: boolean, fields: (keyof T)[], prefer?: Partial 300 | a && this._metas(b, c); 301 | } 302 | else if (typeof a === 'string') {//toggle: keyof T, fields: (keyof T)[], prefer?: Partial 303 | this._metas([a as keyof T], c); 304 | if (meta(this._fmt, a as keyof T)) { 305 | this._metas(b, c); 306 | } 307 | } 308 | else {//fields: (keyof T)[], prefer?: Partial 309 | this._metas(a, b); 310 | } 311 | return this; 312 | } 313 | 314 | private _metas(fields: (keyof T)[], prefer: Partial): this { 315 | prefer = prefer || {}; 316 | const values = meta(this._fmt, fields); 317 | if (fields) { 318 | for (let k of fields) { 319 | if (k in prefer) { 320 | values[k] = prefer[k]; 321 | } 322 | } 323 | } 324 | this._dump.push({ 325 | objectName: this._fmt.oname, 326 | properties: values as any, 327 | selector: null 328 | }); 329 | return this; 330 | } 331 | 332 | public add(ins: Instance): this { 333 | this._dump.push(ins); 334 | return this; 335 | } 336 | 337 | private _autofill(pname: keyof T, toggle: keyof T, values: FormatInstance[]): this { 338 | const binding = this._fmt.binding(pname); 339 | if (!binding) { 340 | debugger;//should be a bug 341 | } 342 | this.metas([toggle]); 343 | const auto = meta(this._fmt, toggle), cat = __ctx.cat(binding.role); 344 | for (const i of values) { 345 | this._dump.push({ 346 | objectName: this._fmt.oname, 347 | displayName: (i.value !== undefined ? '● ' : '◌ ') + i.name, 348 | selector: cat.selector(i.row), 349 | properties: { [pname]: (auto && i.value === undefined) ? i.auto : (i.value || '') } 350 | }); 351 | } 352 | debugger; 353 | return this; 354 | } 355 | 356 | public labels(bind: Binding, label: keyof T): this; 357 | public labels(bind: Binding, label: keyof T, missing: Func, void>): this; 358 | public labels(bind: Binding, label: keyof T, numeric: true): this; 359 | public labels(bind: Binding, label: keyof T, para?: Func, void> | true): this { 360 | if (!this._fmt.binding(label)) { 361 | debugger; 362 | return this; 363 | } 364 | const { autofill, toggle } = this._fmt.binding(label); 365 | const { role, toggle: customize, pname } = bind, ctx = __ctx; 366 | if ((customize === true || ctx.config(bind.fmt.oname, customize)) && ctx.cat(role)) { 367 | //the target category exists and customization enabled 368 | if (toggle !== true) { 369 | this.metas([toggle]);//add the switch 370 | } 371 | if (toggle === true || this._fmt.config(toggle)) {//when enabled 372 | if (para === true || (!ctx.type(role).numeric)) {//allow numeric or not numeric 373 | this._autofill(label, autofill, ctx.labels(bind, this._fmt.special(label))); 374 | } 375 | } 376 | } 377 | else if (!ctx.cat(role) && para && para !== true) { 378 | para(this); 379 | } 380 | else { 381 | if (toggle === true) { 382 | this.metas([label]); 383 | } 384 | else { 385 | this.metas(toggle, [label]); 386 | } 387 | } 388 | return this; 389 | } 390 | 391 | public specification(pname: keyof T): this { 392 | this.metas([pname]).items(pname); 393 | return this; 394 | } 395 | 396 | public items(test: boolean, pname: keyof T): this; 397 | public items(pname: keyof T): this; 398 | items(a: any, b?: any): this { 399 | let pname = a as keyof T; 400 | if (b) { 401 | if (!a) return this; 402 | pname = b; 403 | } 404 | const binding = this._fmt.binding(pname); 405 | if (!binding) { 406 | debugger;//should be a bug 407 | } 408 | const cat = __ctx.cat(binding.role); 409 | if (!cat) { 410 | return this; 411 | } 412 | if (binding.toggle !== true) { 413 | this.metas([binding.toggle]); 414 | } 415 | if (binding.toggle !== true && !meta(this._fmt, binding.toggle)) { 416 | //toggle is off, so no need to itemize 417 | return this; 418 | } 419 | if (binding.autofill) { 420 | this.metas([binding.autofill]); 421 | } 422 | const rows = cat.distincts(), labeler = cat.row2label(rows); 423 | const item = metaItem(this._fmt, pname), special = this._fmt.special(pname); 424 | const hit = (r: number) => cat.key(r) in special; 425 | const instance = (label: string, r: number) => { 426 | return { 427 | objectName: this._fmt.oname, 428 | displayName: (hit(r) ? '● ' : '◌ ') + label, 429 | selector: cat.selector(r), 430 | properties: { [pname]: item(r) } 431 | } 432 | }; 433 | for (const r of rows) { 434 | this._dump.push(instance(labeler[r], r)); 435 | } 436 | return this; 437 | } 438 | 439 | public get result(): Instance[] { 440 | return this._dump; 441 | } 442 | } --------------------------------------------------------------------------------