├── src ├── assets │ ├── .gitkeep │ ├── ic_logo.png │ ├── ic_logo@2X.png │ └── basemap.json ├── app │ ├── code │ │ ├── code.component.css │ │ └── code.component.ts │ ├── map │ │ ├── map.component.html │ │ ├── map.component.css │ │ └── map.component.ts │ ├── app.component.html │ ├── app.component.css │ ├── terms │ │ ├── terms.component.css │ │ ├── terms.component.html │ │ └── terms.component.ts │ ├── services │ │ ├── geojson.service.ts │ │ ├── firestore.service.ts │ │ ├── analytics.service.ts │ │ ├── styles.service.ts │ │ └── bigquery.service.ts │ ├── app.component.ts │ ├── rule │ │ ├── rule.component.css │ │ ├── rule.component.html │ │ └── rule.component.ts │ ├── file-size.pipe.ts │ ├── app.routing.ts │ ├── app.component.spec.ts │ ├── app.constants.ts │ ├── app.module.ts │ └── main │ │ ├── main.component.css │ │ ├── main.component.html │ │ └── main.component.ts ├── favicon.ico ├── tsconfig.app.json ├── tsconfig.spec.json ├── styles.css ├── typings.d.ts ├── main.ts ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── polyfills.ts ├── index.html ├── test.ts └── theme.scss ├── preview.png ├── assets ├── hero.png ├── sql.png ├── schema.png ├── permissions.png ├── style_fillColor.png ├── style_fillOpacity.png ├── permissions_prompt.png └── style_circleRadius.png ├── .editorconfig ├── tsconfig.json ├── server.js ├── .gitignore ├── CONTRIBUTING.md ├── app.yaml ├── karma.conf.js ├── README.md ├── package.json ├── .github └── workflows │ └── codeql-analysis.yml ├── tslint.json ├── angular.json └── LICENSE /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/code/code.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/map/map.component.html: -------------------------------------------------------------------------------- 1 |
-------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleCloudPlatform/bigquery-geo-viz/HEAD/preview.png -------------------------------------------------------------------------------- /assets/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleCloudPlatform/bigquery-geo-viz/HEAD/assets/hero.png -------------------------------------------------------------------------------- /assets/sql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleCloudPlatform/bigquery-geo-viz/HEAD/assets/sql.png -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleCloudPlatform/bigquery-geo-viz/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /assets/schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleCloudPlatform/bigquery-geo-viz/HEAD/assets/schema.png -------------------------------------------------------------------------------- /assets/permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleCloudPlatform/bigquery-geo-viz/HEAD/assets/permissions.png -------------------------------------------------------------------------------- /src/assets/ic_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleCloudPlatform/bigquery-geo-viz/HEAD/src/assets/ic_logo.png -------------------------------------------------------------------------------- /src/assets/ic_logo@2X.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleCloudPlatform/bigquery-geo-viz/HEAD/src/assets/ic_logo@2X.png -------------------------------------------------------------------------------- /assets/style_fillColor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleCloudPlatform/bigquery-geo-viz/HEAD/assets/style_fillColor.png -------------------------------------------------------------------------------- /assets/style_fillOpacity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleCloudPlatform/bigquery-geo-viz/HEAD/assets/style_fillOpacity.png -------------------------------------------------------------------------------- /src/app/app.component.css: -------------------------------------------------------------------------------- 1 | main { 2 | min-height: 100vh; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | -------------------------------------------------------------------------------- /assets/permissions_prompt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleCloudPlatform/bigquery-geo-viz/HEAD/assets/permissions_prompt.png -------------------------------------------------------------------------------- /assets/style_circleRadius.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleCloudPlatform/bigquery-geo-viz/HEAD/assets/style_circleRadius.png -------------------------------------------------------------------------------- /src/app/map/map.component.css: -------------------------------------------------------------------------------- 1 | :host, 2 | .map { 3 | width: 100%; 4 | height: 100%; 5 | min-width: 45vw; 6 | min-height: calc(100vh - 64px); 7 | } 8 | -------------------------------------------------------------------------------- /src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "outDir": "../out-tsc/app", 6 | "baseUrl": "./", 7 | "module": "es2015" 8 | }, 9 | "exclude": [ 10 | "test.ts", 11 | "**/*.spec.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/app/terms/terms.component.css: -------------------------------------------------------------------------------- 1 | .view { 2 | font-family: Roboto, Helvetica, sans-serif; 3 | padding: 1em; 4 | max-width: 700px; 5 | } 6 | 7 | .flex-spacer { 8 | flex-grow: 1; 9 | } 10 | 11 | nav { 12 | text-align: center; 13 | } 14 | 15 | .back { 16 | margin-top: 2em; 17 | } 18 | 19 | a.policy-link { 20 | color: #4285f4; 21 | font-weight: 400; 22 | } 23 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "baseUrl": "./", 6 | "module": "commonjs", 7 | "target": "es5", 8 | "types": [ 9 | "jasmine", 10 | "node" 11 | ] 12 | }, 13 | "files": [ 14 | "test.ts", 15 | "polyfills.ts" 16 | ], 17 | "include": [ 18 | "**/*.spec.ts", 19 | "**/*.d.ts" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | 3 | @import "../node_modules/codemirror/lib/codemirror.css"; 4 | 5 | html, body { 6 | font-size : 95%; 7 | margin: 0; 8 | padding: 0; 9 | font-family: Roboto, "Helvetica Neue", sans-serif; 10 | } 11 | 12 | color-picker .color-picker { 13 | border: none; 14 | box-shadow: 1px 0px 4px 2px rgba(0, 0, 0, 0.15); 15 | border-radius: 2px; 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "strict": false, 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "moduleResolution": "node", 9 | "emitDecoratorMetadata": true, 10 | "experimentalDecorators": true, 11 | "target": "es2022", 12 | "typeRoots": [ 13 | "node_modules/@types" 14 | ], 15 | "lib": [ 16 | "es2022", 17 | "dom" 18 | ], 19 | "module": "es2022", 20 | "baseUrl": "./" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const serve = require('serve'); 18 | const server = serve('./dist', {port: process.env.PORT}); 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /.angular/cache 8 | 9 | # dependencies 10 | /node_modules 11 | 12 | # IDEs and editors 13 | /.idea 14 | .project 15 | .classpath 16 | .c9/ 17 | *.launch 18 | .settings/ 19 | *.sublime-workspace 20 | 21 | # IDE - VSCode 22 | .vscode/* 23 | !.vscode/settings.json 24 | !.vscode/tasks.json 25 | !.vscode/launch.json 26 | !.vscode/extensions.json 27 | 28 | # misc 29 | /.sass-cache 30 | /connect.lock 31 | /coverage 32 | /libpeerconnection.log 33 | npm-debug.log 34 | testem.log 35 | /typings 36 | 37 | # e2e 38 | /e2e/*.js 39 | /e2e/*.map 40 | 41 | # System Files 42 | .DS_Store 43 | Thumbs.db 44 | 45 | # Dev data 46 | assets/tmp/* 47 | -------------------------------------------------------------------------------- /src/app/services/geojson.service.ts: -------------------------------------------------------------------------------- 1 | import { Feature } from 'geojson'; 2 | 3 | export class GeoJSONService { 4 | 5 | /** 6 | * Converts rows to GeoJSON features. 7 | * @param rows 8 | * @param geoColumn 9 | */ 10 | static rowsToGeoJSON(rows: object[], geoColumn: string): Feature[] { 11 | if (!rows || !geoColumn) { return []; } 12 | 13 | // Convert rows to GeoJSON features. 14 | const features = []; 15 | rows.forEach((row) => { 16 | if (!row[geoColumn]) { return; } 17 | try { 18 | const geometry = JSON.parse(row[geoColumn]); 19 | const feature = { type: 'Feature', geometry, properties: row }; 20 | features.push(feature); 21 | } catch (e) { 22 | // Parsing can fail (e.g. invalid GeoJSON); just log the error. 23 | console.error(e); 24 | } 25 | }); 26 | 27 | return features; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { Component, ViewContainerRef } from '@angular/core'; 18 | 19 | @Component({ 20 | selector: 'app-root', 21 | templateUrl: './app.component.html', 22 | styleUrls: ['./app.component.css'] 23 | }) 24 | export class AppComponent { 25 | readonly title = 'BigQuery Geo Viz'; 26 | constructor(public viewContainerRef: ViewContainerRef) {} 27 | 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/app/terms/terms.component.html: -------------------------------------------------------------------------------- 1 | 2 | BigQuery Geo Viz 3 | 4 | Back 5 | 6 |
7 |

Terms of Service & Privacy

8 |

9 | This tool is provided as a reference implementation of Google Maps and geospatial BigQuery API usage capabilities. It may 10 | be useful as a debugging and visualization resource. It is not an officially supported Google product and is provided without 11 | guarantees of maintenance. 12 |

13 | 18 | Back 19 |
20 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /* SystemJS module definition */ 18 | declare var module: NodeModule; 19 | interface NodeModule { 20 | id: string; 21 | } 22 | 23 | /* Global promises resolved when Google libraries are available */ 24 | declare var pendingMap: Promise; 25 | declare var pendingGapi: Promise; 26 | 27 | /* Google Analytics */ 28 | declare var gtag: (method: string, action: string, detail: Object) => undefined; 29 | 30 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { enableProdMode } from '@angular/core'; 18 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 19 | 20 | import { AppModule } from './app/app.module'; 21 | import { environment } from './environments/environment'; 22 | 23 | import 'codemirror/mode/sql/sql'; 24 | 25 | if (environment.production) { 26 | enableProdMode(); 27 | } 28 | 29 | platformBrowserDynamic().bootstrapModule(AppModule) 30 | .catch(err => console.error(err)); 31 | -------------------------------------------------------------------------------- /src/app/rule/rule.component.css: -------------------------------------------------------------------------------- 1 | mat-slide-toggle { 2 | margin: 1em 0; 3 | } 4 | 5 | mat-form-field { 6 | display: block; 7 | max-width: 250px; 8 | } 9 | 10 | .array-field + .array-field { 11 | margin-top: 1em; 12 | } 13 | 14 | .array-field-list { 15 | display: block; 16 | } 17 | 18 | .array-field-item { 19 | position: relative; 20 | } 21 | 22 | .array-field-input { 23 | border: 1px solid rgba(0, 0, 0, 0.32); 24 | height: 32px; 25 | line-height: 32px; 26 | display: inline-block; 27 | width: 72px; 28 | margin: 0.5em 0.5em 0.5em 0; 29 | padding: 0 1em; 30 | font-family: inherit; 31 | font-size: 1em; 32 | color: rgba(0, 0, 0, 0.54); 33 | } 34 | 35 | .array-field-caption { 36 | position: absolute; 37 | bottom: -1.2em; 38 | font-size: 0.8em; 39 | color: #888; 40 | } 41 | 42 | .array-field-swatch { 43 | display: inline-block; 44 | width: 20px; 45 | height: 20px; 46 | border-radius: 2px; 47 | position: absolute; 48 | right: 1em; 49 | top: 1em; 50 | } 51 | 52 | button:not([disabled]) mat-icon.create { 53 | color: #33AC71; 54 | } 55 | -------------------------------------------------------------------------------- /src/app/terms/terms.component.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { Component, AfterViewInit } from '@angular/core'; 18 | import {MatToolbarModule} from '@angular/material/toolbar'; 19 | 20 | @Component({ 21 | selector: 'app-terms', 22 | templateUrl: './terms.component.html', 23 | styleUrls: ['./terms.component.css'] 24 | }) 25 | export class TermsComponent implements AfterViewInit { 26 | constructor() {} 27 | 28 | /** 29 | * Constructs a Maps API instance after DOM has initialized. 30 | */ 31 | ngAfterViewInit() { 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution; 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | 25 | ## Community Guidelines 26 | 27 | This project follows [Google's Open Source Community 28 | Guidelines](https://opensource.google.com/conduct/). 29 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | runtime: python312 16 | 17 | handlers: 18 | - url: / 19 | secure: always 20 | static_files: index.html 21 | upload: index.html 22 | http_headers: 23 | X-Frame-Options: DENY 24 | 25 | - url: /project/(.*) 26 | secure: always 27 | static_files: index.html 28 | upload: index.html 29 | http_headers: 30 | X-Frame-Options: DENY 31 | 32 | - url: /terms 33 | secure: always 34 | static_files: index.html 35 | upload: index.html 36 | http_headers: 37 | X-Frame-Options: DENY 38 | 39 | - url: /(.*) 40 | secure: always 41 | static_files: \1 42 | upload: (.*) 43 | -------------------------------------------------------------------------------- /src/app/file-size.pipe.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {Pipe, PipeTransform} from '@angular/core'; 18 | 19 | const UNITS = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; 20 | 21 | @Pipe({name: 'fileSize'}) 22 | export class FileSizePipe implements PipeTransform { 23 | 24 | transform(bytes: number = 0, precision: number = 2): string { 25 | if (!isFinite(bytes)) { return ''; } 26 | 27 | let unit; 28 | for (unit = 0; bytes >= 1024; unit++) { 29 | bytes /= 1024; 30 | } 31 | 32 | const value = bytes.toFixed(Number(precision)); 33 | return `${value} ${UNITS[unit]}`; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | export const environment = { 18 | production: true, 19 | authClientID: '419125973937-kl2cru5pu2vfugne7lr1hosgseh4lo1s.apps.googleusercontent.com', 20 | authScope: 'https://www.googleapis.com/auth/bigquery' 21 | }; 22 | 23 | // Your web app's Firebase configuration 24 | export const firebaseConfig = { 25 | apiKey: "AIzaSyDS8k-x7L9vZ_mvvdyTzwQ1LNXsYLNnhOM", 26 | authDomain: "bigquerygeoviz.firebaseapp.com", 27 | databaseURL: "https://bigquerygeoviz.firebaseio.com", 28 | projectId: "bigquerygeoviz", 29 | storageBucket: "bigquerygeoviz.appspot.com", 30 | messagingSenderId: "419125973937", 31 | appId: "1:419125973937:web:eba1c63d64b58be3ec2390", 32 | measurementId: "G-FNH2K1BP5G" 33 | }; 34 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** Evergreen browsers require these. **/ 22 | // Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove. 23 | 24 | 25 | /*************************************************************************************************** 26 | * Zone JS is required by Angular itself. 27 | */ 28 | import 'zone.js/dist/zone'; // Included with Angular CLI. 29 | 30 | /*************************************************************************************************** 31 | * APPLICATION IMPORTS 32 | */ 33 | // See https://github.com/valor-software/ng2-dragula/issues/849. 34 | (window as any).global = window; 35 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | export const environment = { 18 | production: false, 19 | authClientID: '419125973937-kl2cru5pu2vfugne7lr1hosgseh4lo1s.apps.googleusercontent.com', 20 | authScope: 'https://www.googleapis.com/auth/bigquery' 21 | }; 22 | 23 | // Your web app's Firebase configuration 24 | // TODO(hormati): Create a different config for testing. 25 | export const firebaseConfig = { 26 | apiKey: "AIzaSyDS8k-x7L9vZ_mvvdyTzwQ1LNXsYLNnhOM", 27 | authDomain: "bigquerygeoviz.firebaseapp.com", 28 | databaseURL: "https://bigquerygeoviz.firebaseio.com", 29 | projectId: "bigquerygeoviz", 30 | storageBucket: "bigquerygeoviz.appspot.com", 31 | messagingSenderId: "419125973937", 32 | appId: "1:419125973937:web:eba1c63d64b58be3ec2390", 33 | measurementId: "G-FNH2K1BP5G" 34 | }; 35 | -------------------------------------------------------------------------------- /src/app/app.routing.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { Routes } from '@angular/router'; 18 | import { TermsComponent } from './terms/terms.component'; 19 | import { MainComponent } from './main/main.component'; 20 | export const routes: Routes = [ 21 | { 22 | path: '', 23 | component: MainComponent 24 | }, 25 | { 26 | path: 'terms', 27 | component: TermsComponent 28 | }, 29 | { 30 | path: 'project', 31 | children: [ 32 | { 33 | path: ':project/dataset/:dataset/table/:table', 34 | component: MainComponent 35 | }, 36 | { 37 | path: ':project/job', 38 | children: [ 39 | { 40 | path: ':job/location/:location', 41 | component: MainComponent 42 | }, 43 | { 44 | path: ':job', 45 | component: MainComponent 46 | }, 47 | ] 48 | }, 49 | ] 50 | }, 51 | ]; 52 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BigQuery Geo Viz 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 23 | 24 | 25 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // Karma configuration file, see link for more information 18 | // https://karma-runner.github.io/1.0/config/configuration-file.html 19 | 20 | module.exports = function (config) { 21 | config.set({ 22 | basePath: '', 23 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 24 | plugins: [ 25 | require('karma-jasmine'), 26 | require('karma-chrome-launcher'), 27 | require('karma-jasmine-html-reporter'), 28 | require('karma-coverage-istanbul-reporter'), 29 | require('@angular-devkit/build-angular/plugins/karma') 30 | ], 31 | client:{ 32 | clearContext: false // leave Jasmine Spec Runner output visible in browser 33 | }, 34 | coverageIstanbulReporter: { 35 | dir: require('path').join(__dirname, 'coverage'), reports: [ 'html', 'lcovonly' ], 36 | fixWebpackSourcePaths: true 37 | }, 38 | 39 | reporters: ['progress', 'kjhtml'], 40 | port: 9876, 41 | colors: true, 42 | logLevel: config.LOG_INFO, 43 | autoWatch: true, 44 | browsers: ['Chrome'], 45 | singleRun: false 46 | }); 47 | }; 48 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { TestBed, async } from '@angular/core/testing'; 18 | import { AppComponent } from './app.component'; 19 | describe('AppComponent', () => { 20 | beforeEach(async(() => { 21 | TestBed.configureTestingModule({ 22 | declarations: [ 23 | AppComponent 24 | ], 25 | }).compileComponents(); 26 | })); 27 | it('should create the app', async(() => { 28 | const fixture = TestBed.createComponent(AppComponent); 29 | const app = fixture.debugElement.componentInstance; 30 | expect(app).toBeTruthy(); 31 | })); 32 | it(`should have as title 'app'`, async(() => { 33 | const fixture = TestBed.createComponent(AppComponent); 34 | const app = fixture.debugElement.componentInstance; 35 | expect(app.title).toEqual('app'); 36 | })); 37 | it('should render title in a h1 tag', async(() => { 38 | const fixture = TestBed.createComponent(AppComponent); 39 | fixture.detectChanges(); 40 | const compiled = fixture.debugElement.nativeElement; 41 | expect(compiled.querySelector('h1').textContent).toContain('Welcome to app!'); 42 | })); 43 | }); 44 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 18 | 19 | import 'zone.js/dist/long-stack-trace-zone'; 20 | import 'zone.js/dist/proxy.js'; 21 | import 'zone.js/dist/sync-test'; 22 | import 'zone.js/dist/jasmine-patch'; 23 | import 'zone.js/dist/async-test'; 24 | import 'zone.js/dist/fake-async-test'; 25 | import { getTestBed } from '@angular/core/testing'; 26 | import { 27 | BrowserDynamicTestingModule, 28 | platformBrowserDynamicTesting 29 | } from '@angular/platform-browser-dynamic/testing'; 30 | 31 | // Unfortunately there's no typing for the `__karma__` variable. Just declare it as any. 32 | declare const __karma__: any; 33 | declare const require: any; 34 | 35 | // Prevent Karma from running prematurely. 36 | __karma__.loaded = function () {}; 37 | 38 | // First, initialize the Angular testing environment. 39 | getTestBed().initTestEnvironment( 40 | BrowserDynamicTestingModule, 41 | platformBrowserDynamicTesting() 42 | ); 43 | // Then we find all the tests. 44 | const context = require.context('./', true, /\.spec\.ts$/); 45 | // And load the modules. 46 | context.keys().map(context); 47 | // Finally, start Karma to run the tests. 48 | __karma__.start(); 49 | -------------------------------------------------------------------------------- /src/theme.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | 3 | @include mat.core(); 4 | 5 | // primary: #00539b 6 | // accent: #d3e9ef 7 | // white: #ffffff 8 | // grey: #e5e3df 9 | 10 | $primary: ( 11 | 50: #e0eaf3, 12 | 100: #b3cbe1, 13 | 200: #80a9cd, 14 | 300: #4d87b9, 15 | 400: #266daa, 16 | 500: #00539b, 17 | 600: #004c93, 18 | 700: #004289, 19 | 800: #00397f, 20 | 900: #00296d, 21 | A100: #d3e9ef, 22 | A200: #d3e9ef, 23 | A400: #d3e9ef, 24 | A700: #d3e9ef, 25 | contrast: ( 26 | 50: rgba(black, 0.87), 27 | 100: rgba(black, 0.87), 28 | 200: rgba(black, 0.87), 29 | 300: rgba(black, 0.87), 30 | 400: rgba(black, 0.87), 31 | 500: white, 32 | 600: white, 33 | 700: white, 34 | 800: white, 35 | 900: white, 36 | A100: rgba(black, 0.87), 37 | A200: rgba(black, 0.87), 38 | A400: rgba(black, 0.87), 39 | A700: white, 40 | ) 41 | ); 42 | 43 | $secondary: ( 44 | 50: #d3e9ef, 45 | 100: #d3e9ef, 46 | 200: #d3e9ef, 47 | 300: #d3e9ef, 48 | 400: #d3e9ef, 49 | 500: #d3e9ef, 50 | 600: #d3e9ef, 51 | 700: #d3e9ef, 52 | 800: #d3e9ef, 53 | 900: #d3e9ef, 54 | A100: #d3e9ef, 55 | A200: #d3e9ef, 56 | A400: #d3e9ef, 57 | A700: #d3e9ef, 58 | contrast: ( 59 | 50: rgba(black, 0.87), 60 | 100: rgba(black, 0.87), 61 | 200: rgba(black, 0.87), 62 | 300: rgba(black, 0.87), 63 | 400: rgba(black, 0.87), 64 | 500: white, 65 | 600: white, 66 | 700: white, 67 | 800: white, 68 | 900: white, 69 | A100: rgba(black, 0.87), 70 | A200: rgba(black, 0.87), 71 | A400: rgba(black, 0.87), 72 | A700: white, 73 | ) 74 | ); 75 | $bqmapper-primary: mat.define-palette($primary); 76 | $bqmapper-accent: mat.define-palette($secondary, 500, 900, A100); 77 | $bqmapper-warn: mat.define-palette(mat.$red-palette); 78 | 79 | $bqmapper-theme: mat.define-light-theme(( 80 | color:( 81 | primary: $bqmapper-primary, 82 | accent: $bqmapper-accent, 83 | warn: $bqmapper-warn) 84 | )); 85 | 86 | @include mat.all-component-themes($bqmapper-theme); 87 | -------------------------------------------------------------------------------- /src/app/app.constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as colorbrewer from 'colorbrewer'; 18 | 19 | export const Step = { 20 | DATA: 0, 21 | SCHEMA: 1, 22 | STYLE: 2, 23 | SHARE: 3 24 | }; 25 | 26 | // Maximum number of results to be returned by BigQuery API. 27 | export const MAX_RESULTS = 5000000; 28 | 29 | // Maximum number of results to be shown in the HTML preview table. 30 | export const MAX_RESULTS_PREVIEW = 10; 31 | 32 | // How long to wait for the query to complete, in milliseconds, before the request times out and returns. 33 | export const TIMEOUT_MS = 120000; 34 | 35 | // Used to write the sharing data and maintain backward compatibility. 36 | export const SHARING_VERSION = "v1"; 37 | 38 | export const SAMPLE_PROJECT_ID = 'google.com:bqmapper'; 39 | export const SAMPLE_QUERY = `SELECT 40 | ST_GeogPoint(longitude, latitude) AS WKT, 41 | status, 42 | health, 43 | spc_common, 44 | user_type, 45 | problems, 46 | tree_dbh 47 | FROM \`bigquery-public-data.new_york_trees.tree_census_2015\` 48 | WHERE status = 'Alive' 49 | LIMIT 50000;`; 50 | 51 | // Each page is 10MB. This means the total data will be 250MB at most.. 52 | export const MAX_PAGES = 25; 53 | 54 | export const SAMPLE_FILL_OPACITY = {isComputed: false, value: 0.8}; 55 | export const SAMPLE_FILL_COLOR = { 56 | isComputed: true, 57 | property: 'health', 58 | function: 'categorical', 59 | domain: ['Poor', 'Fair', 'Good'], 60 | range: ['#F44336', '#FFC107', '#4CAF50'] 61 | }; 62 | export const SAMPLE_CIRCLE_RADIUS = { 63 | isComputed: true, 64 | property: 'tree_dbh', 65 | function: 'linear', 66 | domain: [0, 500], 67 | range: [10, 50] 68 | }; 69 | 70 | export const PALETTES = Object.keys(colorbrewer).map((key) => colorbrewer[key]); 71 | -------------------------------------------------------------------------------- /src/app/services/firestore.service.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // Firebase App (the core Firebase SDK) is always required and 18 | // must be listed before other Firebase SDKs 19 | import * as firebase from "firebase/app"; 20 | 21 | // Load required services into the firebase namespace. 22 | import "firebase/auth"; 23 | import "firebase/firestore"; 24 | 25 | import {firebaseConfig} from '../../environments/environment'; 26 | 27 | const SHARING_COLLECTION = 'GeoVizSharing'; 28 | 29 | export interface ShareableData { 30 | sharingVersion: string; 31 | projectID: string; 32 | jobID: string; 33 | location: string | undefined; 34 | styles: string; 35 | creationTimestampMs: number; 36 | } 37 | 38 | /** 39 | * Utility class for managing interaction with the Firestore. 40 | */ 41 | export class FirestoreService { 42 | private db: firebase.firestore.Firestore = null; 43 | 44 | constructor() { 45 | // Initialize Firebase 46 | firebase.initializeApp(firebaseConfig); 47 | this.db = firebase.firestore(); 48 | } 49 | 50 | storeShareableData(shareableData: ShareableData): Promise { 51 | return this.db.collection(SHARING_COLLECTION).add(shareableData) 52 | .then(function (docRef) { 53 | return docRef.id; 54 | }); 55 | } 56 | 57 | getSharedData(docId: string): Promise { 58 | return this.db.collection(SHARING_COLLECTION).doc(docId).get().then(function (doc) { 59 | if (!doc.exists) { 60 | throw new Error('Shared visualization does not exist. Please check your URL!'); 61 | } 62 | return doc.data() as ShareableData; 63 | }); 64 | } 65 | 66 | authorize(credential: object) { 67 | const firebase_credential = firebase.auth.GoogleAuthProvider.credential( 68 | credential['id_token'], 69 | credential['access_token'] 70 | ); 71 | firebase.auth().signInWithCredential(firebase_credential); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BigQuery Geo Viz 2 | 3 | Web tool and developer example for visualization of Google BigQuery geospatial data using Google Maps Platform APIs. 4 | 5 | ![preview](preview.png) 6 | 7 | ## Getting started 8 | 9 | - Tool: https://bigquerygeoviz.appspot.com/ 10 | - Documentation: https://cloud.google.com/bigquery/docs/gis-analyst-start 11 | 12 | ## Development 13 | 14 | ### Quickstart 15 | 16 | ```shell 17 | # Start a dev server at http://localhost:4200/. 18 | npm run dev 19 | 20 | # Run unit tests with Karma. 21 | npm test 22 | ``` 23 | 24 | ### Deploy to your dev project and version 25 | 26 | This will run `npm run build`, copy the `app.yaml` into the `dist/` directory 27 | and deploy the app from there at the specified project and version ID. 28 | 29 | ```shell 30 | npm run deploy --project=my-project --app_version=my-version-id 31 | ``` 32 | 33 | Once deployed, the version will be available to test at the specified project 34 | and version, e.g.: 35 | https://my-version-id-dot-my-project.appspot.com 36 | 37 | ### Deploy to beta (for GeoViz maintainers) 38 | 39 | ```shell 40 | npm run deploy:beta 41 | ``` 42 | Once deployed, the version will be available to test here: 43 | https://beta-dot-bigquerygeoviz.appspot.com 44 | 45 | ### Deploy to a (prod) version (for GeoViz maintainers) 46 | 47 | ```shell 48 | npm run deploy:prod --app_version=my-version-id 49 | ``` 50 | 51 | Once deployed, the version will be available to test at the specified version: 52 | https://my-version-id-dot-bigquerygeoviz.appspot.com 53 | 54 | To prevent accidentally pushing it live, the version will not be set as default. 55 | To make it the default version, you will need to 56 | [migrate it directly on the Cloud Console](https://cloud.google.com/appengine/docs/legacy/standard/python/migrating-traffic). 57 | 58 | ### Resources 59 | 60 | - [Google Maps JavaScript API documentation](https://developers.google.com/maps/documentation/javascript/) 61 | - [Google BigQuery REST API documentation](https://cloud.google.com/bigquery/docs/reference/rest/v2/) 62 | - [Angular](https://angular.io/) 63 | - [D3.js](https://d3js.org/) 64 | - [TypeScript](https://www.typescriptlang.org/) 65 | 66 | ## Terms & privacy 67 | 68 | This tool is provided as a reference implementation of Google Maps and geospatial BigQuery API usage capabilities. It may 69 | be useful as a debugging and visualization resource. It is not an officially supported Google product and is provided without 70 | guarantees of maintenance. 71 | 72 | - [Google Terms of Service](https://policies.google.com/terms) 73 | - [Google Cloud Platform Terms of Service](https://cloud.google.com/terms/) 74 | - [Privacy](https://policies.google.com/privacy) 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bigquerygeoviz", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "start": "node server.js", 7 | "ng": "ng", 8 | "dev": "ng serve", 9 | "build": "ng build", 10 | "test": "ng test", 11 | "lint": "ng lint", 12 | "predeploy": "npm run build", 13 | "deploy": "cp app.yaml dist/app.yaml && cd dist && gcloud app deploy --project $npm_config_project --version $npm_config_app_version --no-promote", 14 | "deploy:beta": "npm run deploy --project=bigquerygeoviz --app_version=beta", 15 | "deploy:prod": "npm run deploy --project=bigquerygeoviz --app_version=$npm_config_app_version" 16 | }, 17 | "private": true, 18 | "dependencies": { 19 | "@angular/animations": "^16.2.12", 20 | "@angular/cdk": "^16.2.14", 21 | "@angular/common": "^16.2.12", 22 | "@angular/compiler": "^16.2.12", 23 | "@angular/core": "^16.2.12", 24 | "@angular/forms": "^16.2.12", 25 | "@angular/google-maps": "^16.2.3", 26 | "@angular/material": "^16.2.14", 27 | "@angular/platform-browser": "^16.2.12", 28 | "@angular/platform-browser-dynamic": "^16.2.12", 29 | "@angular/router": "^16.2.12", 30 | "@deck.gl/core": "^9.0.36", 31 | "@deck.gl/google-maps": "^9.0.36", 32 | "@deck.gl/layers": "^9.0.36", 33 | "@lezer/lr": "^1.4.2", 34 | "@turf/bbox": "^7.1.0", 35 | "@turf/meta": "^7.2.0", 36 | "@types/codemirror": "5.60.15", 37 | "@types/crypto-js": "^4.2.2", 38 | "@types/d3-scale": "^4.0.8", 39 | "@types/gapi": "0.0.47", 40 | "@types/gapi.auth2": "^0.0.61", 41 | "angular-split": "^17.2.0", 42 | "codemirror": "5.65.18", 43 | "colorbrewer": "^1.5.7", 44 | "core-js": "^3.39.0", 45 | "crypto-js": "^4.2.0", 46 | "d3-color": "^1.4.0", 47 | "d3-scale": "^1.0.7", 48 | "firebase": "7.24.0", 49 | "js-file-download": "^0.4.12", 50 | "ngx-color-picker": "^17.0.0", 51 | "ngx-webstorage-service": "^5.0.0", 52 | "rxjs": "^7.8.1", 53 | "serve": "^14.2.4", 54 | "zone.js": "~0.13.0" 55 | }, 56 | "devDependencies": { 57 | "@angular-devkit/build-angular": "~16.2.16", 58 | "@angular/cli": "^16.2.16", 59 | "@angular/compiler-cli": "^16.2.12", 60 | "@angular/language-service": "^16.2.12", 61 | "@types/jasmine": "~5.1.5", 62 | "@types/jasminewd2": "^2.0.13", 63 | "@types/node": "^22.10.1", 64 | "codelyzer": "~6.0.2", 65 | "jasmine-core": "~5.4.0", 66 | "jasmine-spec-reporter": "~7.0.0", 67 | "karma": "^6.4.4", 68 | "karma-chrome-launcher": "~3.2.0", 69 | "karma-cli": "~2.0.0", 70 | "karma-coverage-istanbul-reporter": "^3.0.3", 71 | "karma-jasmine": "~5.1.0", 72 | "karma-jasmine-html-reporter": "^2.1.0", 73 | "protractor": "^7.0.0", 74 | "sass": "^1.69.7", 75 | "ts-node": "~10.9.2", 76 | "tslint": "~6.1.3", 77 | "typescript": "^5.1.6" 78 | }, 79 | "browser": { 80 | "crypto": false 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/app/code/code.component.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * MIT License, Copyright (c) 2016 Simon Babay and Google. 3 | * 4 | * Source: https://github.com/chymz/ng2-codemirror 5 | */ 6 | 7 | import {Component, Input, Output, ViewChild, EventEmitter, forwardRef} from '@angular/core'; 8 | import {NG_VALUE_ACCESSOR} from '@angular/forms'; 9 | import * as CodeMirror from 'codemirror'; 10 | 11 | export type CodeEditorMode = 'sql'; 12 | 13 | /** 14 | * Code component, wraps CodeMirror 15 | * Usage : 16 | * 17 | */ 18 | @Component({ 19 | selector: 'codemirror', 20 | providers: [ 21 | { 22 | provide: NG_VALUE_ACCESSOR, 23 | useExisting: forwardRef(() => CodeComponent), 24 | multi: true 25 | } 26 | ], 27 | template: ``, 28 | styleUrls: ['./code.component.css'] 29 | }) 30 | export class CodeComponent { 31 | @Input() 32 | mode: CodeEditorMode = 'sql'; 33 | 34 | @Output() change = new EventEmitter(); 35 | @Output() query = new EventEmitter(); 36 | editor; 37 | @ViewChild('host', {static: true}) host: any; 38 | 39 | _value = ''; 40 | @Output() instance = null; 41 | 42 | /** 43 | * Constructor 44 | */ 45 | constructor() { 46 | } 47 | 48 | get value(): any { 49 | return this._value; 50 | } 51 | 52 | @Input() set value(v) { 53 | if (v !== this._value) { 54 | this._value = v; 55 | this.onChange(v); 56 | } 57 | } 58 | 59 | /** 60 | * On component destroy 61 | */ 62 | ngOnDestroy() { 63 | } 64 | 65 | /** 66 | * On component view init 67 | */ 68 | ngAfterViewInit() { 69 | this.instance = CodeMirror.fromTextArea( 70 | this.host.nativeElement, 71 | { 72 | value: this.value ? this.value : '', 73 | mode: this.mode, 74 | lineNumbers: true, 75 | lineWrapping: true 76 | } 77 | ); 78 | 79 | this.instance.on('change', () => { 80 | this.updateValue(this.instance.getValue()); 81 | }); 82 | 83 | this.instance.setOption('extraKeys', { 84 | 'Ctrl-Enter': () => { 85 | this.query.emit(true); 86 | } 87 | }); 88 | } 89 | 90 | /** 91 | * Value update process 92 | */ 93 | updateValue(value) { 94 | this.value = value; 95 | this.onChange(value); 96 | this.onTouched(); 97 | this.change.emit(value); 98 | } 99 | 100 | /** 101 | * Implements ControlValueAccessor 102 | */ 103 | writeValue(value) { 104 | this._value = value || ''; 105 | if (this.instance) { 106 | this.instance.setValue(this._value); 107 | } 108 | } 109 | 110 | onChange(_) { 111 | } 112 | 113 | onTouched() { 114 | } 115 | 116 | registerOnChange(fn) { 117 | this.onChange = fn; 118 | } 119 | 120 | registerOnTouched(fn) { 121 | this.onTouched = fn; 122 | } 123 | } 124 | 125 | 126 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '28 9 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /src/app/services/analytics.service.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * Defer a function call until no further calls after the wait time (in milliseconds). 19 | */ 20 | export function debounce(callback: Function, wait: number) { 21 | let timeoutId = null; 22 | return (...args: any) => { 23 | window.clearTimeout(timeoutId); 24 | timeoutId = window.setTimeout(() => { 25 | callback(...args); 26 | }, wait); 27 | }; 28 | } 29 | 30 | export class AnalyticsService { 31 | /** 32 | * Benchmark and report a Promise (typically a network call). 33 | */ 34 | async reportBenchmark(action: string, category: string, promise: Promise) { 35 | const t0 = performance.now(); 36 | const result = await promise; 37 | const t1 = performance.now(); 38 | try { 39 | this.report(action, category, /* label= */ '', Math.round(t1 - t0)); 40 | } catch(e: unknown) { 41 | console.error(e); 42 | } 43 | return result; 44 | } 45 | 46 | /** 47 | * Records an event. 48 | * @param action The event action. 49 | * @param category The event category. 50 | * @param label The optional event label. 51 | * @param value An optional numeric value associated with the event. 52 | */ 53 | report(action: string, category = '', label: string = '', value = 1) { 54 | this.send(action, category, label, value); 55 | } 56 | 57 | /** 58 | * Sends a Google Analytics request. 59 | * @param action The event action. 60 | * @param category The event category. 61 | * @param label The optional event label. 62 | * @param value An optional numeric value associated with the event. 63 | */ 64 | private send(action: string, category: string, label: string, value: number) { 65 | const payload = { 66 | 'value': value, 67 | }; 68 | if (category) { 69 | payload['event_category'] = category; 70 | } 71 | if (label) { 72 | payload['event_label'] = label; 73 | } 74 | // Actually send the request. 75 | this.sendPayload(action, payload); 76 | } 77 | 78 | /** 79 | * Sends a Google Analytics request. 80 | * @param action The event action. 81 | * @param payload The payload to send. 82 | */ 83 | private sendPayload(action: string, payload: Object) { 84 | if (!window['gtag'] || !(window['gtag'] instanceof Function)) { 85 | return; 86 | } 87 | const tracker = window['gtag']; 88 | try { 89 | tracker.apply(window, ['event', action, payload]); 90 | } catch(e: unknown) { 91 | console.error(e); 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {NgModule, enableProdMode} from '@angular/core'; 18 | 19 | import {routes} from './app.routing'; 20 | import {RouterModule} from '@angular/router'; 21 | 22 | import {MatInputModule} from '@angular/material/input'; 23 | import {MatSidenavModule} from '@angular/material/sidenav'; 24 | import {MatToolbarModule} from '@angular/material/toolbar'; 25 | import {MatButtonModule} from '@angular/material/button'; 26 | import {MatIconModule} from '@angular/material/icon'; 27 | import {MatStepperModule} from '@angular/material/stepper'; 28 | import {MatSelectModule} from '@angular/material/select'; 29 | import {MatTableModule} from '@angular/material/table'; 30 | import {MatAutocompleteModule} from '@angular/material/autocomplete'; 31 | import {MatExpansionModule} from '@angular/material/expansion'; 32 | import {MatSnackBarModule} from '@angular/material/snack-bar'; 33 | import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; 34 | import {MatSlideToggleModule} from '@angular/material/slide-toggle'; 35 | import {MatTooltipModule} from '@angular/material/tooltip'; 36 | 37 | import {FormsModule, ReactiveFormsModule} from '@angular/forms'; 38 | 39 | import {AppComponent} from './app.component'; 40 | import {MainComponent} from './main/main.component'; 41 | import {MapComponent} from './map/map.component'; 42 | import {TermsComponent} from './terms/terms.component'; 43 | import {RuleInputComponent} from './rule/rule.component'; 44 | import {FileSizePipe} from './file-size.pipe'; 45 | import {ColorPickerModule} from 'ngx-color-picker'; 46 | import {CodeComponent} from './code/code.component'; 47 | import {BrowserModule} from '@angular/platform-browser'; 48 | import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; 49 | import {AngularSplitModule} from 'angular-split'; 50 | 51 | import {environment} from '../environments/environment'; 52 | 53 | 54 | if (environment.production) { 55 | enableProdMode(); 56 | } 57 | 58 | @NgModule({ 59 | declarations: [ 60 | AppComponent, 61 | MainComponent, 62 | MapComponent, 63 | TermsComponent, 64 | RuleInputComponent, 65 | FileSizePipe, 66 | CodeComponent 67 | ], 68 | imports: [ 69 | RouterModule.forRoot(routes), 70 | BrowserModule, 71 | BrowserAnimationsModule, 72 | MatInputModule, 73 | MatSidenavModule, 74 | MatToolbarModule, 75 | MatButtonModule, 76 | MatIconModule, 77 | MatStepperModule, 78 | MatSelectModule, 79 | MatAutocompleteModule, 80 | MatTableModule, 81 | MatExpansionModule, 82 | MatSnackBarModule, 83 | MatProgressSpinnerModule, 84 | MatSlideToggleModule, 85 | MatTooltipModule, 86 | FormsModule, 87 | ReactiveFormsModule, 88 | ColorPickerModule, 89 | AngularSplitModule 90 | ], 91 | providers: [], 92 | bootstrap: [AppComponent] 93 | }) 94 | export class AppModule { 95 | } 96 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "node_modules/codelyzer" 4 | ], 5 | "rules": { 6 | "arrow-return-shorthand": true, 7 | "callable-types": true, 8 | "class-name": true, 9 | "comment-format": [ 10 | true, 11 | "check-space" 12 | ], 13 | "curly": true, 14 | "eofline": true, 15 | "forin": true, 16 | "import-blacklist": [ 17 | true, 18 | "rxjs/Rx" 19 | ], 20 | "import-spacing": true, 21 | "indent": [ 22 | true, 23 | "spaces" 24 | ], 25 | "interface-over-type-literal": true, 26 | "label-position": true, 27 | "max-line-length": [ 28 | true, 29 | 140 30 | ], 31 | "member-access": false, 32 | "member-ordering": [ 33 | true, 34 | { 35 | "order": [ 36 | "static-field", 37 | "instance-field", 38 | "static-method", 39 | "instance-method" 40 | ] 41 | } 42 | ], 43 | "no-arg": true, 44 | "no-bitwise": true, 45 | "no-console": [ 46 | true, 47 | "debug", 48 | "info", 49 | "time", 50 | "timeEnd", 51 | "trace" 52 | ], 53 | "no-construct": true, 54 | "no-debugger": true, 55 | "no-duplicate-super": true, 56 | "no-empty": false, 57 | "no-empty-interface": true, 58 | "no-eval": true, 59 | "no-inferrable-types": [ 60 | true, 61 | "ignore-params" 62 | ], 63 | "no-misused-new": true, 64 | "no-non-null-assertion": true, 65 | "no-shadowed-variable": true, 66 | "no-string-literal": false, 67 | "no-string-throw": true, 68 | "no-switch-case-fall-through": true, 69 | "no-trailing-whitespace": true, 70 | "no-unnecessary-initializer": true, 71 | "no-unused-expression": true, 72 | "no-use-before-declare": true, 73 | "no-var-keyword": true, 74 | "object-literal-sort-keys": false, 75 | "one-line": [ 76 | true, 77 | "check-open-brace", 78 | "check-catch", 79 | "check-else", 80 | "check-whitespace" 81 | ], 82 | "prefer-const": true, 83 | "quotemark": [ 84 | true, 85 | "single" 86 | ], 87 | "radix": true, 88 | "semicolon": [ 89 | true, 90 | "always" 91 | ], 92 | "triple-equals": [ 93 | true, 94 | "allow-null-check" 95 | ], 96 | "typedef-whitespace": [ 97 | true, 98 | { 99 | "call-signature": "nospace", 100 | "index-signature": "nospace", 101 | "parameter": "nospace", 102 | "property-declaration": "nospace", 103 | "variable-declaration": "nospace" 104 | } 105 | ], 106 | "typeof-compare": true, 107 | "unified-signatures": true, 108 | "variable-name": false, 109 | "whitespace": [ 110 | true, 111 | "check-branch", 112 | "check-decl", 113 | "check-operator", 114 | "check-separator", 115 | "check-type" 116 | ], 117 | "directive-selector": [ 118 | true, 119 | "attribute", 120 | "app", 121 | "camelCase" 122 | ], 123 | "component-selector": [ 124 | true, 125 | "element", 126 | "app", 127 | "kebab-case" 128 | ], 129 | "use-input-property-decorator": true, 130 | "use-output-property-decorator": true, 131 | "use-host-property-decorator": true, 132 | "no-input-rename": true, 133 | "no-output-rename": true, 134 | "use-life-cycle-interface": true, 135 | "use-pipe-transform-interface": true, 136 | "component-class-suffix": true, 137 | "directive-class-suffix": true, 138 | "invoke-injectable": true 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/app/rule/rule.component.html: -------------------------------------------------------------------------------- 1 |
2 |

{{ prop.description }}

3 | 4 | Data-driven 5 | 6 | 7 | 12 | 13 | 14 | 18 | None 19 | 24 | {{ fn.name }} 25 | 26 | 27 | 28 | 29 | 33 | None 34 | 35 | {{ column }} 36 | 37 | 38 | 39 |
40 | 41 | 48 | 54 |
55 | 57 | 58 | 60 | {{ first ? ('min: ' + stats.min) : ('max: ' + stats.max) }} 61 | 62 | 63 |
64 |
65 |
66 | 67 | 74 |
75 | 76 | 79 | 86 | {{d.value}} 87 | 88 | 89 | 90 |
91 |
92 |
93 | -------------------------------------------------------------------------------- /src/app/main/main.component.css: -------------------------------------------------------------------------------- 1 | .view { 2 | display: flex; 3 | flex-direction: row-reverse; 4 | } 5 | 6 | .header-profile { 7 | font-size: 14px; 8 | padding: 0 16px; 9 | } 10 | 11 | .header-logo { 12 | height: 32px; 13 | margin-right: 0.5em; 14 | } 15 | 16 | .header-logo-text { 17 | font-size: large; 18 | } 19 | 20 | .flex-spacer { 21 | flex-grow: 1; 22 | } 23 | 24 | .toolbar-divider { 25 | padding: 0 16px; 26 | } 27 | 28 | .drawer { 29 | max-width: 600px; 30 | max-height: calc(100vh - 64px); 31 | overflow: auto; 32 | flex-shrink: 0; 33 | } 34 | 35 | .sidenav-container { 36 | position: fixed; 37 | left: 0; 38 | right: 0; 39 | } 40 | 41 | .stepper { 42 | max-width: 100%; 43 | overflow: auto; 44 | flex-shrink: 0; 45 | } 46 | 47 | mat-progress-spinner { 48 | display: inline-block; 49 | vertical-align: middle; 50 | } 51 | 52 | .apply-style-button { 53 | margin-top: 0.5em; 54 | } 55 | 56 | .create-share-link-button { 57 | margin-bottom: 0.5em; 58 | } 59 | 60 | .toggle-button, 61 | .preset-button { 62 | position: fixed; 63 | bottom: 1em; 64 | background: #00539b; 65 | z-index: 10000; 66 | } 67 | 68 | .toggle-button { 69 | left: 1em; 70 | } 71 | 72 | .preset-button { 73 | /* spacing + buttonWidth + spacing */ 74 | left: calc(1em + 40px + 1em); 75 | } 76 | 77 | .sql-form-field { 78 | display: block; 79 | width: 100%; 80 | max-width: 600px; 81 | } 82 | 83 | .sql-lint { 84 | font-size: 0.8em; 85 | color: crimson; 86 | } 87 | 88 | .sql-caption { 89 | color: #888; 90 | font-size: 0.8em; 91 | } 92 | 93 | .sql-location { 94 | margin-top: 1em; 95 | display: block; 96 | } 97 | 98 | .num-results-text { 99 | font-size: 0.8em; 100 | } 101 | 102 | .result-table { 103 | overflow-x: auto; 104 | } 105 | 106 | .result-table-cell { 107 | overflow: hidden; 108 | white-space: nowrap; 109 | text-overflow: ellipsis; 110 | } 111 | 112 | .styles-prop-list .rule-badge { 113 | font-size: 0.6em; 114 | font-weight: 500; 115 | display: inline-block; 116 | padding: 0.5em; 117 | color: #ffffff; 118 | border-radius: 2px; 119 | } 120 | 121 | .styles-prop-list mat-panel-title { 122 | width: 150px; 123 | flex-grow: 0; 124 | } 125 | 126 | .styles-prop-list .rule-badge.computed { 127 | background: #00539b; 128 | } 129 | 130 | .styles-prop-list .rule-badge.global { 131 | background: #F4B400; 132 | } 133 | 134 | .styles-prop-list .rule-badge.none { 135 | background: #e5e3df; 136 | color: #000000; 137 | } 138 | 139 | .mat-toolbar.mat-primary, 140 | .mat-raised-button.mat-primary, 141 | .mat-step-header .mat-step-icon { 142 | color: #FFFFFF; 143 | } 144 | 145 | .mat-button.mat-primary .mat-button-focus-overlay { 146 | background-color: red; 147 | } 148 | 149 | .mat-raised-button[disabled] { 150 | background: #e5e3df; 151 | } 152 | 153 | .mat-toolbar a { 154 | text-decoration: none; 155 | } 156 | 157 | .mat-toolbar h6 h5 { 158 | font: 500 20px/32px Roboto, "Helvetica Neue", sans-serif 159 | } 160 | 161 | .mat-button { 162 | font-family: Roboto, "Helvetica Neue", sans-serif; 163 | font-size: 14px; 164 | font-weight: 500; 165 | } 166 | 167 | .feedback-link { 168 | vertical-align: super; 169 | } 170 | 171 | .info-msg, 172 | .success-msg, 173 | .warning-msg, 174 | .error-msg { 175 | margin: 10px 0; 176 | padding: 5px; 177 | border-radius: 3px 3px 3px 3px; 178 | } 179 | 180 | .info-msg { 181 | align-items: center; 182 | background-color: #BEF; 183 | color: #059; 184 | display: flex; 185 | flex-direction: row; 186 | justify-content: space-around; 187 | } 188 | 189 | .info-msg a { 190 | color: #059; 191 | text-decoration: underline; 192 | } 193 | 194 | .info-msg span { 195 | margin: 0 4px; 196 | } 197 | 198 | .success-msg { 199 | color: #270; 200 | background-color: #DFF2BF; 201 | } 202 | 203 | .warning-msg { 204 | color: #9F6000; 205 | background-color: #FEEFB3; 206 | } 207 | 208 | .error-msg { 209 | color: #D8000C; 210 | background-color: #FFBABA; 211 | } -------------------------------------------------------------------------------- /src/assets/basemap.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "featureType": "all", 4 | "elementType": "geometry.fill", 5 | "stylers": [ 6 | { 7 | "weight": "2.00" 8 | } 9 | ] 10 | }, 11 | { 12 | "featureType": "all", 13 | "elementType": "geometry.stroke", 14 | "stylers": [ 15 | { 16 | "color": "#9c9c9c" 17 | } 18 | ] 19 | }, 20 | { 21 | "featureType": "all", 22 | "elementType": "labels.text", 23 | "stylers": [ 24 | { 25 | "visibility": "on" 26 | } 27 | ] 28 | }, 29 | { 30 | "featureType": "landscape", 31 | "elementType": "all", 32 | "stylers": [ 33 | { 34 | "color": "#f2f2f2" 35 | } 36 | ] 37 | }, 38 | { 39 | "featureType": "landscape", 40 | "elementType": "geometry.fill", 41 | "stylers": [ 42 | { 43 | "color": "#ffffff" 44 | } 45 | ] 46 | }, 47 | { 48 | "featureType": "landscape.man_made", 49 | "elementType": "geometry.fill", 50 | "stylers": [ 51 | { 52 | "color": "#ffffff" 53 | } 54 | ] 55 | }, 56 | { 57 | "featureType": "poi", 58 | "elementType": "all", 59 | "stylers": [ 60 | { 61 | "visibility": "off" 62 | } 63 | ] 64 | }, 65 | { 66 | "featureType": "road", 67 | "elementType": "all", 68 | "stylers": [ 69 | { 70 | "saturation": -100 71 | }, 72 | { 73 | "lightness": 45 74 | } 75 | ] 76 | }, 77 | { 78 | "featureType": "road", 79 | "elementType": "geometry.fill", 80 | "stylers": [ 81 | { 82 | "color": "#eeeeee" 83 | } 84 | ] 85 | }, 86 | { 87 | "featureType": "road", 88 | "elementType": "labels.text.fill", 89 | "stylers": [ 90 | { 91 | "color": "#7b7b7b" 92 | } 93 | ] 94 | }, 95 | { 96 | "featureType": "road", 97 | "elementType": "labels.text.stroke", 98 | "stylers": [ 99 | { 100 | "color": "#ffffff" 101 | } 102 | ] 103 | }, 104 | { 105 | "featureType": "road.highway", 106 | "elementType": "all", 107 | "stylers": [ 108 | { 109 | "visibility": "simplified" 110 | } 111 | ] 112 | }, 113 | { 114 | "featureType": "road.arterial", 115 | "elementType": "labels.icon", 116 | "stylers": [ 117 | { 118 | "visibility": "off" 119 | } 120 | ] 121 | }, 122 | { 123 | "featureType": "transit", 124 | "elementType": "all", 125 | "stylers": [ 126 | { 127 | "visibility": "off" 128 | } 129 | ] 130 | }, 131 | { 132 | "featureType": "water", 133 | "elementType": "all", 134 | "stylers": [ 135 | { 136 | "color": "#d3e9ef" 137 | }, 138 | { 139 | "visibility": "on" 140 | } 141 | ] 142 | }, 143 | { 144 | "featureType": "water", 145 | "elementType": "geometry.fill", 146 | "stylers": [ 147 | { 148 | "color": "#d3e9ef" 149 | } 150 | ] 151 | }, 152 | { 153 | "featureType": "water", 154 | "elementType": "labels.text.fill", 155 | "stylers": [ 156 | { 157 | "color": "#070707" 158 | } 159 | ] 160 | }, 161 | { 162 | "featureType": "water", 163 | "elementType": "labels.text.stroke", 164 | "stylers": [ 165 | { 166 | "color": "#ffffff" 167 | } 168 | ] 169 | } 170 | ] -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "geoviz": { 7 | "root": "", 8 | "sourceRoot": "src", 9 | "projectType": "application", 10 | "architect": { 11 | "build": { 12 | "builder": "@angular-devkit/build-angular:browser", 13 | "options": { 14 | "stylePreprocessorOptions": { 15 | "includePaths": [ 16 | "src/scss" 17 | ] 18 | }, 19 | "outputPath": "dist", 20 | "index": "src/index.html", 21 | "main": "src/main.ts", 22 | "tsConfig": "src/tsconfig.app.json", 23 | "polyfills": "src/polyfills.ts", 24 | "assets": [ 25 | "src/assets", 26 | "src/favicon.ico" 27 | ], 28 | "styles": [ 29 | "src/theme.scss", 30 | "src/styles.css", 31 | "node_modules/codemirror/lib/codemirror.css" 32 | ], 33 | "scripts": [] 34 | }, 35 | "configurations": { 36 | "production": { 37 | "optimization": true, 38 | "outputHashing": "all", 39 | "sourceMap": false, 40 | "extractCss": true, 41 | "namedChunks": false, 42 | "aot": true, 43 | "extractLicenses": true, 44 | "vendorChunk": false, 45 | "buildOptimizer": true, 46 | "fileReplacements": [ 47 | { 48 | "replace": "src/environments/environment.ts", 49 | "with": "src/environments/environment.prod.ts" 50 | } 51 | ] 52 | }, 53 | "development": { 54 | "buildOptimizer": false, 55 | "optimization": false, 56 | "vendorChunk": true, 57 | "extractLicenses": false, 58 | "sourceMap": true, 59 | "namedChunks": true, 60 | "fileReplacements": [ { 61 | "replace": "src/environments/environment.ts", 62 | "with": "src/environments/environment.prod.ts" 63 | } 64 | ] 65 | }, 66 | "defaultConfiguration": "development" 67 | } 68 | }, 69 | "serve": { 70 | "builder": "@angular-devkit/build-angular:dev-server", 71 | "options": { 72 | "browserTarget": "geoviz:build:development" 73 | }, 74 | "configurations": { 75 | "production": { 76 | "browserTarget": "geoviz:build:development" 77 | } 78 | } 79 | }, 80 | "extract-i18n": { 81 | "builder": "@angular-devkit/build-angular:extract-i18n", 82 | "options": { 83 | "browserTarget": "geoviz:build" 84 | } 85 | }, 86 | "test": { 87 | "builder": "@angular-devkit/build-angular:karma", 88 | "options": { 89 | "main": "src/test.ts", 90 | "karmaConfig": "./karma.conf.js", 91 | "polyfills": "src/polyfills.ts", 92 | "tsConfig": "src/tsconfig.spec.json", 93 | "scripts": [], 94 | "styles": [ 95 | "src/theme.scss", 96 | "src/styles.css", 97 | "node_modules/codemirror/lib/codemirror.css" 98 | ], 99 | "assets": [ 100 | "src/assets", 101 | "src/favicon.ico" 102 | ] 103 | } 104 | }, 105 | "lint": { 106 | "builder": "@angular-devkit/build-angular:tslint", 107 | "options": { 108 | "tsConfig": [ 109 | "src/tsconfig.app.json", 110 | "src/tsconfig.spec.json" 111 | ], 112 | "exclude": [ 113 | "**/node_modules/**", 114 | "**/third_party/**" 115 | ] 116 | } 117 | } 118 | } 119 | }, 120 | "geoviz-e2e": { 121 | "root": "e2e", 122 | "sourceRoot": "e2e", 123 | "projectType": "application" 124 | } 125 | }, 126 | "schematics": { 127 | "@schematics/angular:component": { 128 | "prefix": "app" 129 | }, 130 | "@schematics/angular:directive": { 131 | "prefix": "app" 132 | } 133 | }, 134 | "cli": { 135 | "analytics": false 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/app/rule/rule.component.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { Component, forwardRef, Input, OnInit } from '@angular/core'; 18 | import { 19 | AbstractControl, 20 | FormArray, 21 | FormControl, 22 | FormGroup, 23 | NG_VALUE_ACCESSOR, 24 | NG_VALIDATORS 25 | } from '@angular/forms'; 26 | import { StyleFunctions, StyleProp, StyleRule } from '../services/styles.service'; 27 | import { ColumnStat } from '../services/bigquery.service'; 28 | import { PALETTES } from '../app.constants'; 29 | 30 | /** 31 | * Custom form control for a single style rule. 32 | */ 33 | @Component({ 34 | selector: 'app-rule-input', 35 | templateUrl: './rule.component.html', 36 | styleUrls: ['./rule.component.css'], 37 | providers: [ 38 | { 39 | provide: NG_VALUE_ACCESSOR, 40 | useExisting: forwardRef(() => RuleInputComponent), 41 | multi: true 42 | }, 43 | { 44 | provide: NG_VALIDATORS, 45 | useExisting: forwardRef(() => RuleInputComponent), 46 | multi: true, 47 | } 48 | ] 49 | }) 50 | export class RuleInputComponent implements OnInit { 51 | StyleFunctions = StyleFunctions; 52 | 53 | @Input() formGroup: FormGroup; 54 | @Input() prop: StyleProp; 55 | @Input() stats: ColumnStat; 56 | @Input() columns: Array = []; 57 | 58 | protected _rule: StyleRule = { 59 | isComputed: false, 60 | value: '', 61 | function: '', 62 | property: '', 63 | domain: [], 64 | range: [] 65 | }; 66 | 67 | onChange = (rule: StyleRule) => {}; 68 | onTouched = (rule: StyleRule) => {}; 69 | 70 | public validate(c: FormControl) { 71 | return null; 72 | } 73 | 74 | ngOnInit() { 75 | // Reflect FormGroup changes to local object used to update view. 76 | this.formGroup.valueChanges.subscribe(() => { 77 | Object.assign(this._rule, this.formGroup.getRawValue()); 78 | }); 79 | } 80 | 81 | writeValue(rule: StyleRule): void { 82 | Object.assign(this._rule, rule); 83 | this.formGroup.patchValue(rule); 84 | this.onChange(rule); 85 | } 86 | 87 | registerOnChange(fn: (rule: StyleRule) => void): void { 88 | this.onChange = fn; 89 | } 90 | 91 | registerOnTouched(fn: () => void): void { 92 | this.onTouched = fn; 93 | } 94 | 95 | getDomainControls(): AbstractControl[] { 96 | const array = this.formGroup.controls.domain; 97 | return array.controls; 98 | } 99 | 100 | getRangeControls(): AbstractControl[] { 101 | const array = this.formGroup.controls.range; 102 | return array.controls; 103 | } 104 | 105 | addDomainRangeValue() { 106 | const control = new FormControl(''); 107 | const domainArray = this.formGroup.controls.domain; 108 | const rangeArray = this.formGroup.controls.range; 109 | domainArray.push(new FormControl('')); 110 | rangeArray.push(new FormControl('')); 111 | } 112 | 113 | removeDomainRangeValue(): void { 114 | const domain = this.formGroup.controls.domain; 115 | const range = this.formGroup.controls.range; 116 | domain.removeAt(domain.length - 1); 117 | range.removeAt(range.length - 1); 118 | } 119 | 120 | /** 121 | * Whether this rule has enough information to be used. 122 | */ 123 | isPropEnabled(): boolean { 124 | const rule = this._rule; 125 | if (!rule.isComputed && rule.value) { return true; } 126 | if (rule.isComputed && rule.function) { return true; } 127 | return false; 128 | } 129 | 130 | /** 131 | * Whether this rule requires domain/range mappings. 132 | */ 133 | getPropNeedsMapping(): boolean { 134 | return this._rule.function && this._rule.function !== 'identity'; 135 | } 136 | 137 | /** 138 | * Replaces current color palette with a random one. 139 | */ 140 | addRandomColors() { 141 | const palette = PALETTES[Math.floor(Math.random() * PALETTES.length)]; 142 | const range = this.formGroup.controls.range; 143 | if (range.length < 3) { 144 | range.setValue(palette[3].slice(0, range.length)); 145 | } else if (range.length < 10) { 146 | range.setValue(palette[range.length]); 147 | } else { 148 | console.warn('No palettes available for 10+ colors.'); 149 | } 150 | } 151 | 152 | /** 153 | * ngx-color-picker doesn't support Reactive Forms, so use a change 154 | * handler to update the form. 155 | */ 156 | onColorChange() { 157 | this.writeValue(this.formGroup.getRawValue()); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/app/services/styles.service.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as d3Scale from 'd3-scale'; 18 | import * as d3Color from 'd3-color'; 19 | 20 | export interface StyleRule { 21 | isComputed: boolean; 22 | value: string; 23 | property: string; 24 | function: string; 25 | domain: string[]; 26 | range: string[]; 27 | } 28 | 29 | const DEFAULT_STYLES = { 30 | fillColor: [255, 0, 0], 31 | fillOpacity: 1.0, 32 | strokeColor: [255, 0, 0], 33 | strokeOpacity: 1.0, 34 | strokeWeight: 1.0, 35 | circleRadius: 25 36 | }; 37 | 38 | const parseNumber = Number; 39 | const parseBoolean = (v) => !!String(v).match(/y|1|t/gi); 40 | const parseColorString = (v) => { 41 | const color = d3Color.color(v); 42 | return color ? String(color) : DEFAULT_STYLES.fillColor; 43 | }; 44 | 45 | export interface StyleProp { 46 | name: string; 47 | type: string; 48 | description: string; 49 | parse: (i: string) => any; 50 | } 51 | 52 | export const StyleProps: Array = [ 53 | { 54 | name: 'fillColor', 55 | type: 'color', 56 | parse: parseColorString, 57 | description: '' 58 | + 'Fill color of a polygon or point. For example, "linear" or "interval" functions may be used' 59 | + ' to map numeric values to a color gradient.' 60 | }, 61 | { 62 | name: 'fillOpacity', 63 | type: 'number', 64 | parse: parseNumber, 65 | description: '' 66 | + 'Fill opacity of a polygon or point. Values must be in the range 0—1, where 0=transparent' 67 | + ' and 1=opaque.' 68 | }, 69 | { 70 | name: 'strokeColor', 71 | type: 'color', 72 | parse: parseColorString, 73 | description: 'Stroke/outline color of a polygon or line.' 74 | }, 75 | { 76 | name: 'strokeOpacity', 77 | type: 'number', 78 | parse: parseNumber, 79 | description: '' 80 | + 'Stroke/outline opacity of polygon or line. Values must be in the range 0—1, where' 81 | + ' 0=transparent and 1=opaque.' 82 | }, 83 | { 84 | name: 'strokeWeight', 85 | type: 'number', 86 | parse: parseNumber, 87 | description: 'Stroke/outline width, in pixels, of a polygon or line.' 88 | }, 89 | { 90 | name: 'circleRadius', 91 | type: 'number', 92 | parse: parseNumber, 93 | description: '' 94 | + 'Radius of the circle representing a point, in meters. For example, a "linear" function' 95 | + ' could be used to map numeric values to point sizes, creating a scatterplot style.' 96 | } 97 | ]; 98 | 99 | export const StyleFunctions = [ 100 | { 101 | name: 'identity', 102 | description: 'Data value of each field is used, verbatim, as the styling value.' 103 | }, 104 | { 105 | name: 'categorical', 106 | description: 'Data values of each field listed in the domain are mapped 1:1 with corresponding styles in the range.' 107 | }, 108 | { 109 | name: 'interval', 110 | description: '' 111 | + 'Data values of each field are rounded down to the nearest value in the domain, then styled' 112 | + ' with the corresponding style in the range.' 113 | }, 114 | { 115 | name: 'linear', 116 | description: '' 117 | + 'Data values of each field are interpolated linearly across values in the domain, then' 118 | + ' styled with a blend of the corresponding styles in the range.' 119 | }, 120 | { 121 | name: 'exponential', 122 | disabled: true, 123 | description: '' 124 | + 'Data values of each field are interpolated exponentially across values in the domain,' 125 | + ' then styled with a blend of the corresponding styles in the range.' 126 | }, 127 | ]; 128 | 129 | export class StylesService { 130 | iconCache: Map = new Map(); 131 | imageCache: Map = new Map(); 132 | scaleCache: Map | d3Scale.ScaleLinear | d3Scale.ScaleThreshold> 133 | = new Map(); 134 | 135 | constructor() { 136 | 137 | } 138 | 139 | uncache() { 140 | this.scaleCache.clear(); 141 | } 142 | 143 | parseStyle(propName: string, row: object, rule: StyleRule) { 144 | const prop = StyleProps.find((p) => p.name === propName); 145 | let scale = this.scaleCache.get(rule); 146 | 147 | if (!rule.isComputed) { 148 | // Static value. 149 | return rule.value 150 | ? prop.parse(rule.value) 151 | : DEFAULT_STYLES[propName]; 152 | 153 | } else if (!rule.property || !rule.function) { 154 | // Default value. 155 | return DEFAULT_STYLES[propName]; 156 | 157 | } else if (rule.function === 'identity') { 158 | // Identity function. 159 | return prop.parse(row[rule.property]); 160 | 161 | } else if (rule.function === 'categorical') { 162 | // Categorical function. 163 | if (!scale) { 164 | const range = rule.range.map((v) => prop.parse(v)); 165 | scale = d3Scale.scaleOrdinal() 166 | .domain(rule.domain) 167 | .range(range) 168 | .unknown(DEFAULT_STYLES[propName]); 169 | this.scaleCache.set(rule, scale); 170 | } 171 | const callableScale = scale as (any) => any; 172 | return callableScale(row[rule.property]); 173 | 174 | } else if (rule.function === 'interval') { 175 | // Interval function. 176 | if (!scale) { 177 | const range = rule.range.map((v) => prop.parse(v)); 178 | const tmpScale = d3Scale.scaleThreshold() 179 | .domain(rule.domain.map(Number)) 180 | .range([...range, DEFAULT_STYLES[propName]]); 181 | scale = tmpScale as any as d3Scale.ScaleThreshold; 182 | this.scaleCache.set(rule, scale); 183 | } 184 | const callableScale = scale as (number) => any; 185 | return callableScale(Number(row[rule.property])); 186 | 187 | } else if (rule.function === 'linear') { 188 | // Linear function. 189 | if (!scale) { 190 | const range = rule.range.map((v) => prop.parse(v)); 191 | scale = d3Scale.scaleLinear() 192 | .domain(rule.domain.map(Number)) 193 | .range(range); 194 | this.scaleCache.set(rule, scale); 195 | } 196 | const callableScale = scale as (number) => any; 197 | return callableScale(Number(row[rule.property])); 198 | 199 | } 200 | throw new Error('Unknown style rule function: ' + rule.function); 201 | } 202 | 203 | getIcon(radius: number, color: string, opacity: number) { 204 | const iconCacheKey = `${radius}:${color}:${opacity}`; 205 | const imageCacheKey = `${color}:${opacity}`; 206 | 207 | // Use cached icon if available. 208 | if (this.iconCache.has(iconCacheKey)) { 209 | return this.iconCache.get(iconCacheKey); 210 | } 211 | 212 | // Use large, scaled icon rather than new image for each size. 213 | const iconRadius = 256; 214 | const iconWidth = 512; 215 | 216 | // Used cached image if available. 217 | if (!this.imageCache.has(imageCacheKey)) { 218 | // Parse color and apply opacity. 219 | const parsedColor = d3Color.color(color); 220 | parsedColor.opacity = opacity; 221 | 222 | // Create canvas and render circle. 223 | const canvas = document.createElement('canvas'); 224 | canvas.height = canvas.width = iconWidth; 225 | const ctx = canvas.getContext('2d'); 226 | ctx.beginPath(); 227 | ctx.arc(iconRadius, iconRadius, iconRadius - 0.5, 0, Math.PI * 2); 228 | ctx.fillStyle = String(parsedColor); 229 | ctx.strokeStyle = null; 230 | ctx.fill(); 231 | 232 | // Cache the image. 233 | this.imageCache.set(imageCacheKey, canvas.toDataURL()); 234 | } 235 | 236 | // Cache and return result. 237 | const icon = { 238 | url: this.imageCache.get(imageCacheKey), 239 | size: new google.maps.Size(iconWidth, iconWidth), 240 | scaledSize: new google.maps.Size(radius * 2, radius * 2), 241 | anchor: new google.maps.Point(radius, radius) 242 | }; 243 | this.iconCache.set(iconCacheKey, icon); 244 | return icon; 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/app/map/map.component.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { Component, ElementRef, Input, NgZone, ViewChild, AfterViewInit, IterableDiffers, IterableDiffer } from '@angular/core'; 18 | import { GeoJsonLayer } from '@deck.gl/layers'; 19 | import { GoogleMapsOverlay } from '@deck.gl/google-maps'; 20 | import bbox from '@turf/bbox'; 21 | import { coordAll } from "@turf/meta"; 22 | 23 | import { AnalyticsService, debounce } from '../services/analytics.service'; 24 | import { GeoJSONService } from '../services/geojson.service'; 25 | import { StylesService, StyleRule } from '../services/styles.service'; 26 | import { Feature } from 'geojson'; 27 | 28 | const LAYER_ID = 'geojson-layer'; 29 | 30 | const INITIAL_VIEW_STATE = { latitude: 45, longitude: 0, zoom: 2, pitch: 0 }; 31 | 32 | const DEFAULT_BATCH_SIZE = 5; 33 | 34 | const GEOMETRY_ANALYTICS_DEBOUNCE_TIMER = 30000; 35 | 36 | @Component({ 37 | selector: 'app-map', 38 | templateUrl: './map.component.html', 39 | styleUrls: ['./map.component.css'] 40 | }) 41 | export class MapComponent implements AfterViewInit { 42 | // DOM element for map. 43 | @ViewChild('mapEl') mapEl: ElementRef; 44 | 45 | // Maps API instance. 46 | map: google.maps.Map; 47 | 48 | // Info window for display over Maps API. 49 | infoWindow: google.maps.InfoWindow; 50 | 51 | // Basemap styles. 52 | pendingStyles: Promise; 53 | 54 | // Styling service. 55 | readonly styler = new StylesService(); 56 | 57 | // Analytics service. 58 | private readonly analyticsService = new AnalyticsService(); 59 | 60 | private readonly reportGeometryAnalytics = debounce(() => { 61 | this._activeGeometryTypes.forEach((type: string) => { 62 | this.analyticsService.report('geometry_type', 'map', type); 63 | }); 64 | this.analyticsService.report( 65 | 'geometry_composition', 66 | 'map', 67 | [...this._activeGeometryTypes].sort().join(' ') 68 | ); 69 | this.analyticsService.report('feature_count', 'map', /* label= */ '', this._features.length); 70 | const vertexCount = this._features.reduce((allVertexCounts, curr) => { 71 | return allVertexCounts + coordAll(curr).length; 72 | }, 0); 73 | this.analyticsService.report('vertex_count', 'map', /* label= */ '', vertexCount); 74 | }, GEOMETRY_ANALYTICS_DEBOUNCE_TIMER); 75 | 76 | private _rows: object[] = []; 77 | private _features: Feature[] = []; 78 | private _styles: StyleRule[] = []; 79 | private _geoColumn: string; 80 | private _activeGeometryTypes = new Set(); 81 | 82 | // Detects how many times we have received new values. 83 | private _numChanges = 0; 84 | // Counts after how many changes we should update the map. 85 | private _batchSize = DEFAULT_BATCH_SIZE; 86 | 87 | private _deckLayer: GoogleMapsOverlay = null; 88 | private _iterableDiffer = null; 89 | 90 | @Input() 91 | set rows(rows: object[]) { 92 | this._rows = rows; 93 | this.resetBatching(); 94 | this.updateFeatures(); 95 | this.updateStyles(); 96 | } 97 | 98 | @Input() 99 | set geoColumn(geoColumn: string) { 100 | this._geoColumn = geoColumn; 101 | this.updateFeatures(); 102 | this.updateStyles(); 103 | } 104 | 105 | @Input() 106 | set styles(styles: StyleRule[]) { 107 | this._styles = styles; 108 | this.updateStyles(); 109 | } 110 | 111 | constructor(private _ngZone: NgZone, iterableDiffers: IterableDiffers) { 112 | this._iterableDiffer = iterableDiffers.find([]).create(null); 113 | this.pendingStyles = fetch('assets/basemap.json', { credentials: 'include' }) 114 | .then((response) => response.json()); 115 | } 116 | 117 | ngDoCheck() { 118 | let changes = this._iterableDiffer.diff(this._rows); 119 | if (changes) { 120 | this._numChanges++; 121 | if (this._numChanges >= this._batchSize) { 122 | this.updateFeatures(); 123 | this.updateStyles(); 124 | this._numChanges = 0; 125 | // Increase the batch size incrementally to keep the overhead low. 126 | this._batchSize = this._batchSize * 1.5; 127 | } 128 | } 129 | } 130 | 131 | /** 132 | * Constructs a Maps API instance after DOM has initialized. 133 | */ 134 | ngAfterViewInit() { 135 | Promise.all([pendingMap, this.pendingStyles]) 136 | .then(([_, mapStyles]) => { 137 | // Initialize Maps API outside of the Angular zone. Maps API binds event listeners, 138 | // and we do NOT want Angular to trigger change detection on these events. Ensuring 139 | // that Maps API interaction doesn't trigger change detection improves performance. 140 | // See: https://blog.angularindepth.com/boosting-performance-of-angular-applications-with-manual-change-detection-42cb396110fb 141 | this._ngZone.runOutsideAngular(() => { 142 | this.map = new google.maps.Map(this.mapEl.nativeElement, { 143 | center: { lat: INITIAL_VIEW_STATE.latitude, lng: INITIAL_VIEW_STATE.longitude }, 144 | zoom: INITIAL_VIEW_STATE.zoom, 145 | tilt: 0 146 | }); 147 | this.map.setOptions({ styles: mapStyles }); 148 | this.infoWindow = new google.maps.InfoWindow({ content: '' }); 149 | this.map.data.addListener('click', (e) => { 150 | this.showInfoWindow(e.feature, e.latLng); 151 | }); 152 | this._deckLayer = new GoogleMapsOverlay({ layers: [] }); 153 | this._deckLayer.setMap(this.map); 154 | this.map.addListener('click', (e) => this._onClick(e)); 155 | }); 156 | }); 157 | } 158 | 159 | _onClick(e: google.maps.MapMouseEvent) { 160 | // TODO(donmccurdy): Do we need a public API for determining when layer is ready? 161 | // if (!this._deckLayer._deck.layerManager) return; 162 | 163 | const { x, y } = e['pixel']; 164 | const picked = this._deckLayer.pickObject({ x, y, radius: 4 }); 165 | 166 | if (picked) { 167 | this.showInfoWindow(picked.object, e.latLng); 168 | } 169 | } 170 | 171 | private resetBatching() { 172 | this._numChanges = 0; 173 | this._batchSize = DEFAULT_BATCH_SIZE; 174 | } 175 | 176 | /** 177 | * Converts row objects into GeoJSON, then loads into Maps API. 178 | */ 179 | updateFeatures() { 180 | if (!this.map) return; 181 | 182 | this._features = GeoJSONService.rowsToGeoJSON(this._rows, this._geoColumn); 183 | 184 | // Note which types of geometry are being shown. 185 | this._activeGeometryTypes.clear(); 186 | this._features.forEach((feature) => { 187 | this._activeGeometryTypes.add(feature.geometry['type']); 188 | }); 189 | 190 | this.reportGeometryAnalytics(); 191 | 192 | // Fit viewport bounds to the data. 193 | const [minX, minY, maxX, maxY] = bbox({ type: 'FeatureCollection', features: this._features }); 194 | const bounds = new google.maps.LatLngBounds( 195 | new google.maps.LatLng(minY, minX), 196 | new google.maps.LatLng(maxY, maxX) 197 | ); 198 | if (!bounds.isEmpty()) { this.map.fitBounds(bounds); } 199 | } 200 | 201 | /** 202 | * Updates styles applied to all GeoJSON features. 203 | */ 204 | updateStyles() { 205 | if (!this.map) { return; } 206 | this.styler.uncache(); 207 | 208 | // Remove old features. 209 | this._deckLayer.setProps({ layers: [] }); 210 | 211 | // Create GeoJSON layer. 212 | const colorRe = /(\d+), (\d+), (\d+)/; 213 | 214 | const layer = new GeoJsonLayer({ 215 | id: LAYER_ID, 216 | data: this._features, 217 | 218 | pickable: true, 219 | autoHighlight: true, 220 | highlightColor: [219, 68, 55], // #DB4437 221 | stroked: this.hasStroke(), 222 | filled: true, 223 | extruded: false, 224 | // elevationScale: 0, 225 | lineWidthUnits: 'pixels', 226 | pointRadiusMinPixels: 1, 227 | getFillColor: d => { 228 | let color = this.getStyle(d, this._styles, 'fillColor'); 229 | if (typeof color === 'string') { 230 | color = color.match(colorRe).slice(1, 4).map(Number); 231 | } 232 | const opacity = this.getStyle(d, this._styles, 'fillOpacity'); 233 | 234 | return [color[0], color[1], color[2], opacity * 256]; 235 | }, 236 | getLineColor: d => { 237 | let color = this.getStyle(d, this._styles, 'strokeColor'); 238 | if (typeof color === 'string') { 239 | color = color.match(colorRe).slice(1, 4).map(Number); 240 | } 241 | const opacity = this.getStyle(d, this._styles, 'strokeOpacity'); 242 | 243 | return [color[0], color[1], color[2], opacity * 256]; 244 | }, 245 | getLineWidth: (d) => this.getStyle(d, this._styles, 'strokeWeight'), 246 | getPointRadius: (d) => this.getStyle(d, this._styles, 'circleRadius'), 247 | updateTriggers: { 248 | getFillColor: [this._styles], 249 | getLineColor: [this._styles], 250 | getLineWidth: [this._styles], 251 | getPointRadius: [this._styles] 252 | } 253 | }); 254 | 255 | this._deckLayer.setProps({ layers: [layer] }); 256 | } 257 | 258 | /** 259 | * Return a given style for a given feature. 260 | * @param feature 261 | * @param style 262 | */ 263 | getStyle(feature, styles: StyleRule[], styleName: string) { 264 | return this.styler.parseStyle(styleName, feature['properties'], styles[styleName]); 265 | } 266 | 267 | /** 268 | * Returns whether the style is currently enabled. 269 | * @param styles 270 | * @param styleName 271 | */ 272 | hasStyle(styles: StyleRule[], styleName: string): boolean { 273 | const rule = styles[styleName]; 274 | if (!rule) return false; 275 | if (!rule.isComputed) return !!rule.value || rule.value === '0'; 276 | return rule.property && rule.function; 277 | } 278 | 279 | hasStroke() { 280 | return this._activeGeometryTypes.has('LineString') 281 | || this._activeGeometryTypes.has('MultiLineString') 282 | || this._activeGeometryTypes.has('Polygon') 283 | || this._activeGeometryTypes.has('MultiPolygon'); 284 | } 285 | 286 | /** 287 | * Displays info window for selected feature. 288 | * @param feature 289 | * @param latLng 290 | */ 291 | showInfoWindow(feature: Feature, latLng: google.maps.LatLng) { 292 | this.infoWindow.setContent(`
${JSON.stringify(feature.properties, null, 2)}
`); 293 | this.infoWindow.open(this.map); 294 | this.infoWindow.setPosition(latLng); 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /src/app/services/bigquery.service.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {environment} from '../../environments/environment'; 18 | import {AnalyticsService} from './analytics.service'; 19 | import {MAX_RESULTS, TIMEOUT_MS} from '../app.constants'; 20 | 21 | export const ColumnType = { 22 | STRING: 'string', 23 | NUMBER: 'number', 24 | LAT: 'latitude', 25 | LNG: 'longitude', 26 | WKT: 'wkt', 27 | DATE: 'date', 28 | ID: 'id' 29 | }; 30 | 31 | export interface ColumnStat { 32 | min: number; 33 | max: number; 34 | nulls: number; 35 | } 36 | 37 | export interface Project { 38 | id: string; 39 | } 40 | 41 | export interface Query { 42 | sql: string; 43 | bytesProcessed: number; 44 | } 45 | 46 | export interface BigQueryColumn { 47 | name: string; 48 | type: string; 49 | mode: string; 50 | } 51 | 52 | export interface BigQuerySchema { 53 | fields: BigQueryColumn[]; 54 | } 55 | 56 | export interface BigQueryDryRunResponse { 57 | ok: boolean; 58 | totalBytesProcessed?: number; 59 | statementType?: string; 60 | schema?: BigQuerySchema; 61 | } 62 | 63 | export interface BigQueryResponse { 64 | error: string | undefined; 65 | columns: Array | undefined; 66 | columnNames: Array | undefined; 67 | rows: Array | undefined; 68 | totalRows: number; 69 | stats: Map | undefined; 70 | pageToken: string | undefined; 71 | jobID: string | undefined; 72 | totalBytesProcessed?: number; 73 | } 74 | 75 | /** 76 | * Utility class for managing interaction with the Cloud BigQuery API. 77 | */ 78 | export class BigQueryService { 79 | private readonly analyticsService = new AnalyticsService(); 80 | 81 | public isSignedIn = false; 82 | public projects: Array = []; 83 | 84 | private signinChangeCallback = () => { 85 | }; 86 | 87 | /** 88 | * Initializes the service. Must be called before any queries are made. 89 | */ 90 | init(): Promise { 91 | // Wait for Google APIs to load, then initialize and try to authenticate. 92 | return pendingGapi 93 | .then(() => { 94 | gapi.client.init({ 95 | clientId: environment.authClientID, 96 | scope: environment.authScope 97 | }) 98 | .then(() => { 99 | gapi['auth2'].getAuthInstance().isSignedIn.listen(((isSignedIn) => { 100 | this.isSignedIn = isSignedIn; 101 | this.signinChangeCallback(); 102 | })); 103 | this.isSignedIn = !!gapi['auth2'].getAuthInstance().isSignedIn.get(); 104 | this.signinChangeCallback(); 105 | }); 106 | }); 107 | } 108 | 109 | /** 110 | * Returns current user details. 111 | */ 112 | getUser(): gapi.auth2.GoogleUser { 113 | return gapi['auth2'].getAuthInstance().currentUser.get(); 114 | } 115 | 116 | /** 117 | * Returns current user credentials. 118 | */ 119 | getCredential(): Object { 120 | let authResponse = gapi['auth2'].getAuthInstance().currentUser.get().getAuthResponse(true); 121 | if (authResponse) { 122 | return {id_token: authResponse.id_token, access_token: authResponse.access_token}; 123 | } 124 | return null; 125 | } 126 | 127 | /** 128 | * Attempts session login. 129 | */ 130 | signin() { 131 | gapi['auth2'].getAuthInstance().signIn().then(() => this.signinChangeCallback()); 132 | } 133 | 134 | /** 135 | * Logs out of current session. 136 | */ 137 | signout() { 138 | this.isSignedIn = false; 139 | gapi['auth2'].getAuthInstance().signOut().then(() => this.signinChangeCallback()); 140 | } 141 | 142 | /** 143 | * Sets callback to be invoked when signin status changes. 144 | * @param callback 145 | */ 146 | onSigninChange(callback): void { 147 | this.signinChangeCallback = callback; 148 | } 149 | 150 | /** 151 | * Queries and returns a list of GCP projects available to the current user. 152 | */ 153 | getProjects(): Promise> { 154 | if (this.projects.length) { 155 | return Promise.resolve(this.projects); 156 | } 157 | 158 | return gapi.client.request({path: `https://www.googleapis.com/bigquery/v2/projects?maxResults=100000`}) 159 | .then((response) => { 160 | this.projects = response.result.projects.slice(); 161 | this.projects.sort((p1, p2) => p1['id'] > p2['id'] ? 1 : -1); 162 | return >this.projects; 163 | }); 164 | } 165 | 166 | /** 167 | * Queries and returns the sql text for a specific job ID. Throws an error if the 168 | * job id is not for a SELECT statement. 169 | */ 170 | getQueryFromJob(jobID: string, location: string, projectID: string): Promise { 171 | const location_param = location ? `location=${location}` : ''; 172 | return gapi.client.request({ 173 | path: `https://www.googleapis.com/bigquery/v2/projects/${projectID}/jobs/${jobID}?${location_param}` 174 | }).then((response) => { 175 | if (!response.result.statistics.query) { 176 | throw new Error('Job id is not for a query job.'); 177 | } 178 | if (response.result.statistics.query.statementType !== 'SELECT') { 179 | throw new Error('Job id is not for a SELECT statement.'); 180 | } 181 | 182 | return {sql: response.result.configuration.query.query, bytesProcessed: Number(response.result.statistics.query.totalBytesProcessed)}; 183 | }); 184 | } 185 | 186 | /** 187 | * Performs a dry run for the given query, and returns estimated bytes to be processed. 188 | * If the dry run fails, returns -1. 189 | * @param projectID 190 | * @param sql 191 | */ 192 | prequery(projectID: string, sql: string, location: string): Promise { 193 | const configuration = { 194 | dryRun: true, 195 | query: { 196 | query: sql, 197 | maxResults: MAX_RESULTS, 198 | timeoutMs: TIMEOUT_MS, 199 | useLegacySql: false 200 | } 201 | }; 202 | if (location) { 203 | configuration.query['location'] = location; 204 | } 205 | return gapi.client.request({ 206 | path: `https://www.googleapis.com/bigquery/v2/projects/${projectID}/jobs`, 207 | method: 'POST', 208 | body: {configuration}, 209 | }).then((response) => { 210 | const {schema, statementType} = response.result.statistics.query; 211 | const totalBytesProcessed = Number(response.result.statistics.query.totalBytesProcessed); 212 | return {ok: true, schema, statementType, totalBytesProcessed}; 213 | }).catch((e) => { 214 | if (e && e.result && e.result.error) { 215 | throw new Error(e.result.error.message); 216 | } 217 | console.warn(e); 218 | return {ok: false}; 219 | }); 220 | } 221 | 222 | normalizeRows(rows: Array, normalizedCols: Array, stats: Map) { 223 | return (rows || []).map((row) => { 224 | const rowObject = {}; 225 | row['f'].forEach(({v}, index) => { 226 | const column = normalizedCols[index]; 227 | if (column['type'] === ColumnType.NUMBER) { 228 | v = v === '' || v === null ? null : Number(v); 229 | rowObject[column['name']] = v; 230 | const stat = stats.get(column['name']); 231 | if (v === null) { 232 | stat.nulls++; 233 | } else { 234 | stat.max = Math.round(Math.max(stat.max, v) * 1000) / 1000; 235 | stat.min = Math.round(Math.min(stat.min, v) * 1000) / 1000; 236 | } 237 | } else { 238 | rowObject[column['name']] = v === null ? null : String(v); 239 | } 240 | }); 241 | return rowObject; 242 | }); 243 | } 244 | 245 | getResults(projectID: string, jobID: string, location: string, pageToken: string, normalized_cols: Array, stats: Map): Promise { 246 | const body = { 247 | maxResults: MAX_RESULTS, 248 | timeoutMs: TIMEOUT_MS, 249 | pageToken: pageToken 250 | }; 251 | if (location) { 252 | body['location'] = location; 253 | } 254 | 255 | return this.analyticsService.reportBenchmark( 256 | 'result_page_load', 257 | 'map', 258 | gapi.client.request({ 259 | path: `https://www.googleapis.com/bigquery/v2/projects/${projectID}/queries/${jobID}`, 260 | method: 'GET', 261 | params: body, 262 | }).then((response) => { 263 | if (response.result.jobComplete === false) { 264 | throw new Error(`Request timed out after ${TIMEOUT_MS / 1000} seconds. This UI does not yet handle longer jobs.`); 265 | } 266 | // Normalize row structure. 267 | const rows = this.normalizeRows(response.result.rows, normalized_cols, stats); 268 | 269 | return {rows, stats, pageToken: response.result.pageToken} as BigQueryResponse; 270 | })); 271 | } 272 | 273 | query(projectID: string, sql: string, location: string): Promise { 274 | const body = { 275 | query: sql, 276 | maxResults: MAX_RESULTS, 277 | timeoutMs: TIMEOUT_MS, 278 | useLegacySql: false 279 | }; 280 | if (location) { 281 | body['location'] = location; 282 | } 283 | return this.analyticsService.reportBenchmark( 284 | 'first_load', 285 | 'map', 286 | gapi.client.request({ 287 | path: `https://www.googleapis.com/bigquery/v2/projects/${projectID}/queries`, 288 | method: 'POST', 289 | body, 290 | }).then((response) => { 291 | const stats = new Map(); 292 | 293 | if (response.result.jobComplete === false) { 294 | throw new Error(`Request timed out after ${TIMEOUT_MS / 1000} seconds. This UI does not yet handle longer jobs.`); 295 | } 296 | 297 | // Normalize column types. 298 | const columnNames = []; 299 | const columns = (response.result.schema.fields || []).map((field) => { 300 | if (isNumericField(field)) { 301 | field.type = ColumnType.NUMBER; 302 | stats.set(field.name, {min: Infinity, max: -Infinity, nulls: 0}); 303 | } else { 304 | field.type = ColumnType.STRING; 305 | } 306 | columnNames.push(field.name); 307 | return field; 308 | }); 309 | 310 | // Normalize row structure. 311 | const rows = this.normalizeRows(response.result.rows, columns, stats); 312 | 313 | if (rows.length === 0) { 314 | throw new Error('No results.'); 315 | } 316 | 317 | const totalRows = Number(response.result.totalRows); 318 | 319 | return { 320 | columns, columnNames, rows, stats, totalRows, pageToken: response.result.pageToken, jobID: response.result.jobReference.jobId, 321 | totalBytesProcessed: Number(response.result.totalBytesProcessed) 322 | } as BigQueryResponse; 323 | })); 324 | } 325 | } 326 | 327 | function isNumericField(field: Object) { 328 | const fieldType = field['type'].toUpperCase(); 329 | return ['INTEGER', 'NUMBER', 'FLOAT', 'DECIMAL'].includes(fieldType); 330 | } 331 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /src/app/main/main.component.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | BigQuery Geo Viz 5 | 6 |
7 | 8 | Announcement: You can now visualize geospatial data on BigQuery Studio. 9 | 10 | 15 |
16 | 17 | 18 | 19 | Feedback 21 | Source 22 | Terms & privacy 23 | | 24 | {{ user?.getBasicProfile().getEmail() }} 25 | Sign out 26 |
27 | 28 |
29 | 30 | 31 | 32 |
33 | 35 | 36 | 37 | 40 | 41 |
42 | 43 | 45 | 46 | 47 | {{ project.id }} 48 | 49 | 50 | 51 | 52 | 55 | 56 |
57 | 60 | 67 | 69 | 70 |

71 | Estimated query size: {{ bytesProcessed | fileSize:1 }} 72 |

73 |

{{ lintMessage }}

74 | 75 | 78 | Auto-select 79 | United States (US) 80 | European Union (EU) 81 | Northern Virginia (us-east4) 82 | Montréal (northamerica-northeast1) 83 | 84 | London (europe-west2) 85 | Finland (europe-north1) 86 | Mumbai (asia-south1) 87 | Singapore (asia-southeast1) 88 | Taiwan (asia-east1) 89 | Tokyo (asia-northeast1) 90 | Sydney (australia-southeast1) 91 | 92 | 93 | 94 |
95 |
96 |
97 | 98 | 99 |
100 | 101 |
102 |
103 | 104 | 107 | {{ column }} 108 | 109 | 110 | 111 | 112 | 114 | 115 |
{{ column }}
116 |
117 | {{ row[column] }} 119 | 120 |
121 | 123 | 124 | 126 |
127 |
128 |
129 | 130 | 131 |
132 | 133 | 134 | 135 | {{ prop.name }} 136 | 137 | data-driven 139 | global 141 | none 143 | 144 | 145 | 148 | 149 | 150 | 153 |
154 |
155 | 156 |

IMPORTANT: 157 | Creating a sharing link will save information about the query and style settings. 158 | Any 159 | user with the link 160 | can restore these settings. However, results returned by the query will not be 161 | stored, 162 | and the the ability 163 | to execute the query and view the results is restricted to users with the necessary 164 | permissions on the 165 | selected Google Cloud Platform project. Sharing links remain active for 30 days. 166 |

167 | 171 | 174 |
175 | 176 | 178 | 179 |
180 |
181 |
182 | 183 |
184 | 185 | 186 | 187 |
188 | 189 | 190 | 192 | 193 | 194 |
195 | 196 | 197 | 206 | 214 |
215 | -------------------------------------------------------------------------------- /src/app/main/main.component.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {Component, ChangeDetectorRef, Inject, NgZone, OnInit, OnDestroy, AfterViewInit} from '@angular/core'; 18 | import {ActivatedRoute} from '@angular/router'; 19 | import {FormBuilder, FormGroup, FormControl, FormArray, Validators} from '@angular/forms'; 20 | import {MatTableDataSource} from '@angular/material/table'; 21 | import {MatSnackBar} from '@angular/material/snack-bar'; 22 | import {StepperSelectionEvent} from '@angular/cdk/stepper'; 23 | 24 | import {LOCAL_STORAGE, StorageService} from 'ngx-webstorage-service'; 25 | 26 | import * as CryptoJS from 'crypto-js'; 27 | 28 | import {Subject, Subscription} from 'rxjs'; 29 | import {debounceTime} from 'rxjs/operators'; 30 | 31 | import {AnalyticsService} from '../services/analytics.service'; 32 | import {StyleProps, StyleRule} from '../services/styles.service'; 33 | import { 34 | BigQueryService, 35 | BigQueryColumn, 36 | ColumnStat, 37 | Project, 38 | BigQueryDryRunResponse, 39 | BigQueryResponse 40 | } from '../services/bigquery.service'; 41 | import {FirestoreService, ShareableData} from '../services/firestore.service'; 42 | import { 43 | Step, 44 | SAMPLE_QUERY, 45 | SAMPLE_FILL_COLOR, 46 | SAMPLE_FILL_OPACITY, 47 | MAX_RESULTS_PREVIEW, 48 | SAMPLE_CIRCLE_RADIUS, 49 | SHARING_VERSION, 50 | MAX_RESULTS, 51 | MAX_PAGES 52 | } from '../app.constants'; 53 | 54 | const DEBOUNCE_MS = 1000; 55 | const USER_QUERY_START_MARKER = '--__USER__QUERY__START__'; 56 | const USER_QUERY_END_MARKER = '--__USER__QUERY__END__'; 57 | 58 | @Component({ 59 | selector: 'app-main', 60 | templateUrl: './main.component.html', 61 | styleUrls: ['./main.component.css'] 62 | }) 63 | 64 | export class MainComponent implements OnInit, OnDestroy, AfterViewInit { 65 | readonly title = 'BigQuery Geo Viz'; 66 | readonly StyleProps = StyleProps; 67 | readonly projectIDRegExp = new RegExp('^[a-z][a-z0-9\.:-]*$', 'i'); 68 | readonly datasetIDRegExp = new RegExp('^[_a-z][a-z_0-9]*$', 'i'); 69 | readonly tableIDRegExp = new RegExp('^[a-z][a-z_0-9]*$', 'i'); 70 | readonly jobIDRegExp = new RegExp('[a-z0-9_-]*$', 'i'); 71 | readonly localStorageKey = 'execution_local_storage_key'; 72 | 73 | // GCP session data 74 | readonly dataService = new BigQueryService(); 75 | readonly storageService = new FirestoreService(); 76 | 77 | private readonly analyticsService = new AnalyticsService(); 78 | 79 | isSignedIn: boolean; 80 | user: gapi.auth2.GoogleUser; 81 | matchingProjects: Array = []; 82 | 83 | // Form groups 84 | dataFormGroup: FormGroup; 85 | schemaFormGroup: FormGroup; 86 | stylesFormGroup: FormGroup; 87 | sharingFormGroup: FormGroup; 88 | 89 | // BigQuery response data 90 | columns: Array; 91 | columnNames: Array; 92 | geoColumnNames: Array; 93 | projectID = ''; 94 | dataset = ''; 95 | table = ''; 96 | jobID = ''; 97 | location = ''; 98 | // This contains the query that ran in the job. 99 | jobWrappedSql = ''; 100 | bytesProcessed: number = 0; 101 | lintMessage = ''; 102 | pending = false; 103 | rows: Array; 104 | totalRows: number = 0; 105 | maxRows: number = MAX_RESULTS; 106 | data: MatTableDataSource; 107 | stats: Map = new Map(); 108 | sideNavOpened: boolean = true; 109 | // If a new query is run or the styling has changed, we need to generate a new sharing id. 110 | sharingDataChanged = false; 111 | // Track if the stepper has actually changed. 112 | stepperChanged = false; 113 | sharingId = ''; // This is the input sharing Id from the url 114 | generatedSharingId = ''; // This is the sharing id generated for the current settings. 115 | sharingIdGenerationPending = false; 116 | 117 | // UI state 118 | stepIndex: Number = 0; 119 | 120 | // Current style rules 121 | styles: Array = []; 122 | 123 | readonly cmDebouncer: Subject = new Subject(); 124 | cmDebouncerSub: Subscription; 125 | 126 | constructor( 127 | @Inject(LOCAL_STORAGE) private _storage: StorageService, 128 | private _formBuilder: FormBuilder, 129 | private _snackbar: MatSnackBar, 130 | private _changeDetectorRef: ChangeDetectorRef, 131 | private _route: ActivatedRoute, 132 | private _ngZone: NgZone) { 133 | 134 | // Debounce CodeMirror change events to avoid running extra dry runs. 135 | this.cmDebouncerSub = this.cmDebouncer 136 | .pipe(debounceTime(DEBOUNCE_MS)) 137 | .subscribe((value: string) => { 138 | this._dryRun(); 139 | }); 140 | 141 | // Set up BigQuery service. 142 | this.dataService.onSigninChange(() => this.onSigninChange()); 143 | this.dataService.init() 144 | .catch((e) => this.showMessage(parseErrorMessage(e))); 145 | } 146 | 147 | ngAfterViewInit(): void { 148 | } 149 | 150 | ngOnInit() { 151 | this.columns = []; 152 | this.columnNames = []; 153 | this.geoColumnNames = []; 154 | this.rows = []; 155 | 156 | // Read parameters from URL 157 | this.projectID = this._route.snapshot.paramMap.get('project'); 158 | this.dataset = this._route.snapshot.paramMap.get('dataset'); 159 | this.table = this._route.snapshot.paramMap.get('table'); 160 | this.jobID = this._route.snapshot.paramMap.get('job'); 161 | this.location = this._route.snapshot.paramMap.get('location') || ''; // Empty string for 'Auto Select' 162 | this.sharingId = this._route.snapshot.queryParams['shareid']; 163 | 164 | // Data form group 165 | this.dataFormGroup = this._formBuilder.group({ 166 | projectID: ['', Validators.required], 167 | sql: ['', Validators.required], 168 | location: [''], 169 | }); 170 | this.dataFormGroup.controls.projectID.valueChanges.pipe(debounceTime(200)).subscribe(() => { 171 | this.dataService.getProjects() 172 | .then((projects) => { 173 | this.matchingProjects = projects.filter((project) => { 174 | return project['id'].indexOf(this.dataFormGroup.controls.projectID.value) >= 0; 175 | }); 176 | }); 177 | }); 178 | 179 | // Schema form group 180 | this.schemaFormGroup = this._formBuilder.group({geoColumn: ['']}); 181 | 182 | // Style rules form group 183 | const stylesGroupMap = {}; 184 | StyleProps.forEach((prop) => stylesGroupMap[prop.name] = this.createStyleFormGroup()); 185 | this.stylesFormGroup = this._formBuilder.group(stylesGroupMap); 186 | 187 | // Sharing form group 188 | this.sharingFormGroup = this._formBuilder.group({ 189 | sharingUrl: '', 190 | }); 191 | 192 | // Initialize default styles. 193 | this.updateStyles(); 194 | } 195 | 196 | saveDataToSharedStorage() { 197 | const dataValues = this.dataFormGroup.getRawValue(); 198 | // Encrypt the style values using the sql string. 199 | const hashedStyleValues = CryptoJS.AES.encrypt(JSON.stringify(this.styles), this.jobWrappedSql + this.bytesProcessed); 200 | const shareableData = { 201 | sharingVersion: SHARING_VERSION, 202 | projectID: dataValues.projectID, 203 | jobID: this.jobID, 204 | location: dataValues.location, 205 | styles: hashedStyleValues.toString(), 206 | creationTimestampMs: Date.now() 207 | }; 208 | 209 | return this.storageService.storeShareableData(shareableData).then((written_doc_id) => { 210 | this.generatedSharingId = written_doc_id; 211 | }); 212 | } 213 | 214 | restoreDataFromSharedStorage(docId: string): Promise { 215 | return this.storageService.getSharedData(this.sharingId); 216 | } 217 | 218 | saveDataToLocalStorage(projectID: string, sql: string, location: string) { 219 | this._storage.set(this.localStorageKey, {projectID: projectID, sql: sql, location: location}); 220 | } 221 | 222 | loadDataFromLocalStorage(): { projectID: string, sql: string, location: string } { 223 | return this._storage.get(this.localStorageKey); 224 | } 225 | 226 | clearDataFromLocalStorage() { 227 | this._storage.remove(this.localStorageKey); 228 | } 229 | 230 | resetUIOnSingout() { 231 | this.clearDataFromLocalStorage(); 232 | this.dataFormGroup.reset(); 233 | this.lintMessage = ''; 234 | } 235 | 236 | ngOnDestroy() { 237 | this.cmDebouncerSub.unsubscribe(); 238 | } 239 | 240 | signin() { 241 | this.clearDataFromLocalStorage(); 242 | this.dataService.signin(); 243 | } 244 | 245 | signout() { 246 | this.resetUIOnSingout(); 247 | this.dataService.signout(); 248 | } 249 | 250 | onSigninChange() { 251 | this._ngZone.run(() => { 252 | this.isSignedIn = this.dataService.isSignedIn; 253 | if (!this.dataService.isSignedIn) { 254 | return; 255 | } 256 | this.user = this.dataService.getUser(); 257 | 258 | this.storageService.authorize(this.dataService.getCredential()); 259 | this.dataService.getProjects() 260 | .then((projects) => { 261 | this.matchingProjects = projects; 262 | this._changeDetectorRef.detectChanges(); 263 | }); 264 | 265 | if (this._hasJobParams() && this._jobParamsValid()) { 266 | this.dataFormGroup.patchValue({ 267 | sql: '/* Loading sql query from job... */', 268 | projectID: this.projectID, 269 | location: this.location 270 | }); 271 | 272 | this.dataService.getQueryFromJob(this.jobID, this.location, this.projectID).then((queryText) => { 273 | this.dataFormGroup.patchValue({ 274 | sql: queryText.sql, 275 | }); 276 | }); 277 | } else if (this._hasTableParams() && this._tableParamsValid()) { 278 | this.dataFormGroup.patchValue({ 279 | sql: `SELECT * 280 | FROM \`${this.projectID}.${this.dataset}.${this.table}\`;`, 281 | projectID: this.projectID, 282 | }); 283 | } else if (this.sharingId) { 284 | this.analyticsService.report('saved_state', 'load', 'from URL'); 285 | this.restoreDataFromSharedStorage(this.sharingId).then((shareableValues) => { 286 | this.applyRetrievedSharingValues(shareableValues); 287 | }).catch((e) => this.showMessage(parseErrorMessage(e))); 288 | } else { 289 | const localStorageValues = this.loadDataFromLocalStorage(); 290 | if (localStorageValues) { 291 | this.dataFormGroup.patchValue({ 292 | sql: localStorageValues.sql, 293 | projectID: localStorageValues.projectID, 294 | location: localStorageValues.location 295 | }); 296 | } 297 | } 298 | }); 299 | } 300 | 301 | applyRetrievedSharingValues(shareableValues: ShareableData) { 302 | if (shareableValues) { 303 | if (shareableValues.sharingVersion != SHARING_VERSION) { 304 | throw new Error('Sharing link is invalid.'); 305 | } 306 | this.dataFormGroup.patchValue({ 307 | sql: '/* Loading sql query from job... */', 308 | projectID: shareableValues.projectID, 309 | location: shareableValues.location 310 | }); 311 | this.dataService.getQueryFromJob(shareableValues.jobID, shareableValues.location, shareableValues.projectID).then((queryText) => { 312 | this.dataFormGroup.patchValue({ 313 | sql: this.convertToUserQuery(queryText.sql), 314 | }); 315 | const unencryptedStyles = JSON.parse(CryptoJS.enc.Utf8.stringify(CryptoJS.AES.decrypt(shareableValues.styles, queryText.sql + queryText.bytesProcessed))); 316 | this.setNumStops(this.stylesFormGroup.controls.fillColor, unencryptedStyles['fillColor'].domain.length); 317 | this.setNumStops(this.stylesFormGroup.controls.fillOpacity, unencryptedStyles['fillOpacity'].domain.length); 318 | this.setNumStops(this.stylesFormGroup.controls.strokeColor, unencryptedStyles['strokeColor'].domain.length); 319 | this.setNumStops(this.stylesFormGroup.controls.strokeOpacity, unencryptedStyles['strokeOpacity'].domain.length); 320 | this.setNumStops(this.stylesFormGroup.controls.strokeWeight, unencryptedStyles['strokeWeight'].domain.length); 321 | this.setNumStops(this.stylesFormGroup.controls.circleRadius, unencryptedStyles['circleRadius'].domain.length); 322 | this.stylesFormGroup.patchValue(unencryptedStyles); 323 | this.updateStyles(); 324 | this.reportStyles(); 325 | }).catch((e) => this.showMessage('Cannot retrieve styling options.')); 326 | } 327 | } 328 | 329 | clearGeneratedSharingUrl() { 330 | this.generatedSharingId = ''; 331 | this.sharingDataChanged = true; 332 | this.sharingFormGroup.patchValue({ 333 | sharingUrl: '' 334 | }); 335 | } 336 | 337 | generateSharingUrl() { 338 | if (!this._hasJobParams()) { 339 | this.showMessage('Please first run a valid query before generating a sharing URL.'); 340 | return; 341 | } 342 | if (this.stepIndex == Step.SHARE && this.stepperChanged && this.sharingDataChanged) { 343 | this.sharingDataChanged = false; 344 | this.sharingIdGenerationPending = true; 345 | 346 | this.saveDataToSharedStorage().then(() => { 347 | this.sharingFormGroup.patchValue({ 348 | sharingUrl: window.location.origin + '?shareid=' + this.generatedSharingId 349 | }); 350 | }).catch((e) => this.showMessage(parseErrorMessage(e))); 351 | } 352 | this.sharingIdGenerationPending = false; 353 | this.analyticsService.report('saved_state', 'share'); 354 | } 355 | 356 | onStepperChange(e: StepperSelectionEvent) { 357 | this.stepIndex = e.selectedIndex; 358 | if (e.selectedIndex !== e.previouslySelectedIndex) { 359 | this.stepperChanged = true; 360 | } else { 361 | this.stepperChanged = false; 362 | } 363 | this.analyticsService.report('step', 'stepper', `step ${this.stepIndex}`); 364 | } 365 | 366 | dryRun() { 367 | this.cmDebouncer.next('next'); 368 | } 369 | 370 | _hasJobParams(): boolean { 371 | return !!(this.jobID && this.projectID); 372 | } 373 | 374 | _hasTableParams(): boolean { 375 | return !!(this.projectID && this.dataset && this.table); 376 | } 377 | 378 | _jobParamsValid(): boolean { 379 | return this.projectIDRegExp.test(this.projectID) && 380 | this.jobIDRegExp.test(this.jobID); 381 | } 382 | 383 | _tableParamsValid(): boolean { 384 | return this.projectIDRegExp.test(this.projectID) && 385 | this.datasetIDRegExp.test(this.dataset) && 386 | this.tableIDRegExp.test(this.table); 387 | } 388 | 389 | _dryRun(): Promise { 390 | const {projectID, sql, location} = this.dataFormGroup.getRawValue(); 391 | if (!projectID) { 392 | return; 393 | } 394 | const dryRun = this.dataService.prequery(projectID, sql, location) 395 | .then((response: BigQueryDryRunResponse) => { 396 | if (!response.ok) { 397 | throw new Error('Query analysis failed.'); 398 | } 399 | const geoColumn = response.schema.fields.find((f) => f.type === 'GEOGRAPHY'); 400 | if (response.statementType !== 'SELECT') { 401 | throw new Error('Expected a SELECT statement.'); 402 | } else if (!geoColumn) { 403 | throw new Error('Expected a geography column, but found none.'); 404 | } 405 | this.lintMessage = ''; 406 | this.bytesProcessed = response.totalBytesProcessed; 407 | return response; 408 | }); 409 | dryRun.catch((e) => { 410 | this.bytesProcessed = -1; 411 | this.lintMessage = parseErrorMessage(e); 412 | }); 413 | return dryRun; 414 | } 415 | 416 | // 'count' is used to track the number of request. Each request is 10MB. 417 | getResults(count: number, projectID: string, inputPageToken: string, location: string, jobID: string): Promise { 418 | if (!inputPageToken || count >= MAX_PAGES) { 419 | // Force an update feature here since everything is done. 420 | this.rows = this.rows.slice(0); 421 | return; 422 | } 423 | count = count + 1; 424 | return this.dataService.getResults(projectID, jobID, location, inputPageToken, this.columns, this.stats).then(({ 425 | rows, 426 | stats, 427 | pageToken 428 | }) => { 429 | this.rows.push(...rows); 430 | this.stats = stats; 431 | this._changeDetectorRef.detectChanges(); 432 | return this.getResults(count, projectID, pageToken, location, jobID); 433 | }); 434 | } 435 | 436 | convertToUserQuery(geovizQuery: string): string { 437 | if (!geovizQuery) { 438 | return ''; 439 | } 440 | 441 | return geovizQuery.substring( 442 | geovizQuery.indexOf(USER_QUERY_START_MARKER) + USER_QUERY_START_MARKER.length, 443 | geovizQuery.indexOf(USER_QUERY_END_MARKER) 444 | ).trim() + '\n'; 445 | } 446 | 447 | convertToGeovizQuery(userQuery: string, geoColumns: BigQueryColumn[], numCols: number): string { 448 | const hasNonGeoColumns = geoColumns.length < numCols; 449 | const nonGeoClause = hasNonGeoColumns 450 | ? `* EXCEPT(${geoColumns.map((f) => `\`${f.name}\``).join(', ')}),` 451 | : ''; 452 | return `SELECT ${nonGeoClause} 453 | ${geoColumns.map((f) => `ST_AsGeoJson(\`${f.name}\`) as \`${f.name}\``).join(', ')} 454 | FROM ( 455 | ${USER_QUERY_START_MARKER} 456 | ${userQuery.replace(/;\s*$/, '')} 457 | ${USER_QUERY_END_MARKER} 458 | );`; 459 | } 460 | 461 | query() { 462 | if (this.pending) { 463 | return; 464 | } 465 | this.pending = true; 466 | 467 | // We will save the query information to local store to be restored next 468 | // time that the app is launched. 469 | const dataFormValues = this.dataFormGroup.getRawValue(); 470 | this.projectID = dataFormValues.projectID; 471 | const sql = dataFormValues.sql; 472 | this.location = dataFormValues.location; 473 | this.saveDataToLocalStorage(this.projectID, sql, this.location); 474 | 475 | // Clear the existing sharing URL. 476 | this.clearGeneratedSharingUrl(); 477 | 478 | let geoColumns; 479 | 480 | this._dryRun() 481 | .then((dryRunResponse) => { 482 | geoColumns = dryRunResponse.schema.fields.filter((f) => f.type === 'GEOGRAPHY'); 483 | 484 | // Wrap the user's SQL query, replacing geography columns with GeoJSON. 485 | this.jobWrappedSql = this.convertToGeovizQuery(sql, geoColumns, dryRunResponse.schema.fields.length); 486 | return this.dataService.query(this.projectID, this.jobWrappedSql, this.location); 487 | }) 488 | .then(({columns, columnNames, rows, stats, totalRows, pageToken, jobID, totalBytesProcessed}) => { 489 | this.columns = columns; 490 | this.columnNames = columnNames; 491 | this.geoColumnNames = geoColumns.map((f) => f.name); 492 | this.rows = rows; 493 | this.stats = stats; 494 | this.data = new MatTableDataSource(rows.slice(0, MAX_RESULTS_PREVIEW)); 495 | this.schemaFormGroup.patchValue({geoColumn: geoColumns[0].name}); 496 | this.totalRows = totalRows; 497 | this.jobID = jobID; 498 | this.bytesProcessed = totalBytesProcessed; 499 | return this.analyticsService.reportBenchmark( 500 | 'load_complete', 501 | 'map', 502 | this.getResults(0, this.projectID, pageToken, this.location, jobID) 503 | ); 504 | }) 505 | .catch((e) => { 506 | const error = e && e.result && e.result.error || {}; 507 | if (error.status === 'INVALID_ARGUMENT' && error.message.match(/^Unrecognized name: f\d+_/)) { 508 | this.showMessage( 509 | 'Geography columns must provide a name. For example, "SELECT ST_GEOGPOINT(1,2)" could ' + 510 | 'be changed to "SELECT ST_GEOGPOINT(1,2) geo".' 511 | ); 512 | } else { 513 | this.showMessage(parseErrorMessage(e)); 514 | } 515 | }) 516 | .then(() => { 517 | this.pending = false; 518 | this._changeDetectorRef.detectChanges(); 519 | }); 520 | 521 | } 522 | 523 | onApplyStylesClicked() { 524 | this.clearGeneratedSharingUrl(); 525 | this.updateStyles(); 526 | this.reportStyles(); 527 | } 528 | 529 | updateStyles() { 530 | if (this.stylesFormGroup.invalid) { 531 | return; 532 | } 533 | this.styles = this.stylesFormGroup.getRawValue(); 534 | } 535 | 536 | /** 537 | * Reports the currently selected styles to Analytics. 538 | * 539 | * The key is the visualization property, e.g., `fillColor`, and the label is one of: 540 | * - `global`, 541 | * - `none`, or 542 | * - for data-driven styles, the function used (`linear`, `interval` etc.). 543 | */ 544 | private reportStyles() { 545 | for (const styleProperty of Object.keys(this.stylesFormGroup.getRawValue())) { 546 | const style = this.styles[styleProperty]; 547 | if (style?.isComputed && style?.function) { 548 | this.analyticsService.report(`${styleProperty}`, 'visualize', style.function); 549 | } else if (!style?.isComputed && style?.value) { 550 | this.analyticsService.report(`${styleProperty}`, 'visualize', 'global'); 551 | } else { 552 | this.analyticsService.report(`${styleProperty}`, 'visualize', 'none'); 553 | } 554 | } 555 | } 556 | 557 | getRowWidth() { 558 | return (this.columns.length * 100) + 'px'; 559 | } 560 | 561 | onFillPreset() { 562 | switch (this.stepIndex) { 563 | case Step.DATA: 564 | this.dataFormGroup.patchValue({sql: SAMPLE_QUERY}); 565 | break; 566 | case Step.SCHEMA: 567 | this.schemaFormGroup.patchValue({geoColumn: 'WKT'}); 568 | break; 569 | case Step.STYLE: 570 | this.setNumStops(this.stylesFormGroup.controls.fillColor, SAMPLE_FILL_COLOR.domain.length); 571 | this.setNumStops(this.stylesFormGroup.controls.circleRadius, SAMPLE_CIRCLE_RADIUS.domain.length); 572 | this.stylesFormGroup.controls.fillOpacity.patchValue(SAMPLE_FILL_OPACITY); 573 | this.stylesFormGroup.controls.fillColor.patchValue(SAMPLE_FILL_COLOR); 574 | this.stylesFormGroup.controls.circleRadius.patchValue(SAMPLE_CIRCLE_RADIUS); 575 | break; 576 | default: 577 | console.warn(`Unexpected step index, ${this.stepIndex}.`); 578 | } 579 | this.analyticsService.report('preset', 'stepper', `step ${this.stepIndex}`); 580 | } 581 | 582 | setNumStops(group: FormGroup, numStops: number): void { 583 | const domain = group.controls.domain; 584 | const range = group.controls.range; 585 | while (domain.length !== numStops) { 586 | if (domain.length < numStops) { 587 | domain.push(new FormControl('')); 588 | range.push(new FormControl('')); 589 | } 590 | if (domain.length > numStops) { 591 | domain.removeAt(domain.length - 1); 592 | range.removeAt(range.length - 1); 593 | } 594 | } 595 | } 596 | 597 | createStyleFormGroup(): FormGroup { 598 | return this._formBuilder.group({ 599 | isComputed: [false], 600 | value: [''], 601 | property: [''], 602 | function: [''], 603 | domain: this._formBuilder.array([[''], ['']]), 604 | range: this._formBuilder.array([[''], ['']]) 605 | }); 606 | } 607 | 608 | getPropStatus(propName: string): string { 609 | const rule = this.stylesFormGroup.controls[propName].value; 610 | if (!rule.isComputed && rule.value) { 611 | return 'global'; 612 | } 613 | if (rule.isComputed && rule.function) { 614 | return 'computed'; 615 | } 616 | return 'none'; 617 | } 618 | 619 | getPropStats(propName: string): ColumnStat { 620 | const group = this.stylesFormGroup.controls[propName]; 621 | const rawValue = group.value; 622 | if (!rawValue.property) { 623 | return null; 624 | } 625 | return this.stats.get(rawValue.property); 626 | } 627 | 628 | getPropFormGroup(propName: string): FormGroup { 629 | return this.stylesFormGroup.controls[propName]; 630 | } 631 | 632 | showMessage(message: string, duration: number = 5000) { 633 | console.warn(message); 634 | this._ngZone.run(() => { 635 | this._snackbar.open(message, undefined, {duration: duration}); 636 | }); 637 | } 638 | } 639 | 640 | function parseErrorMessage(e, defaultMessage = 'Something went wrong') { 641 | if (e.message) { 642 | return e.message; 643 | } 644 | if (e.result && e.result.error && e.result.error.message) { 645 | return e.result.error.message; 646 | } 647 | return defaultMessage; 648 | } 649 | --------------------------------------------------------------------------------