├── 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 |
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 |
14 | Google Terms of Service
15 | Google Cloud Platform Terms of Service
16 | Privacy
17 |
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 | 
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 |
Domain
41 |
46 | add_circle
47 |
48 |
52 | remove_circle
53 |
54 |
55 |
57 |
58 |
60 | {{ first ? ('min: ' + stats.min) : ('max: ' + stats.max) }}
61 |
62 |
63 |
64 |
65 |
66 |
Range
67 |
72 | refresh
73 |
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 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
35 |
36 |
37 |
38 | Authorize
39 |
40 |
41 |
96 |
97 |
98 |
99 |
100 | Add styles
101 |
102 |
128 |
129 |
130 |
131 |
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 | Create Share Link
170 |
171 |
174 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
204 |
205 |
206 |
207 |
208 |
212 |
213 |
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 |
--------------------------------------------------------------------------------