Skip to content

Commit

Permalink
Merge pull request #4 from CartoDB/feat/add-maps-module
Browse files Browse the repository at this point in the history
Add maps module
  • Loading branch information
VictorVelarde authored Jun 9, 2020
2 parents 58c0ea0 + 84ec830 commit 0856c57
Show file tree
Hide file tree
Showing 5 changed files with 445 additions and 0 deletions.
265 changes: 265 additions & 0 deletions src/lib/maps/Client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import { Credentials } from '../core/Credentials';
import errorHandlers from './errors';
import { encodeParameter, getRequest, postRequest } from './utils';

const REQUEST_GET_MAX_URL_LENGTH = 2048;
const VECTOR_EXTENT = 2048;
const VECTOR_SIMPLIFY_EXTENT = 2048;

export class Client {
private _credentials: Credentials;

constructor(credentials: Credentials) {
this._credentials = credentials;
}

/**
* Instantiate a map based on dataset name or a sql query, returning a layergroup
*
* @param options
*/
public async instantiateMapFrom(options: MapOptions) {
const {
sql,
dataset,
vectorExtent = VECTOR_EXTENT,
vectorSimplifyExtent = VECTOR_SIMPLIFY_EXTENT,
metadata = {},
aggregation = {},
bufferSize
} = options;

if (!(sql || dataset)) {
throw new Error('Please provide a dataset or a SQL query');
}

const mapConfig = {
version: '1.3.1',
buffersize: bufferSize,
layers: [
{
type: 'mapnik',
options: {
sql: sql || `select * from ${dataset}`,
vector_extent: vectorExtent,
vector_simplify_extent: vectorSimplifyExtent,
metadata,
aggregation
}
}
]
};

return this.instantiateMap(mapConfig);
}

public static generateMapConfigFromSource(source: string) {
const type = source.search(' ') > -1 ? 'sql' : 'dataset';

return {
[type]: source,
vectorExtent: VECTOR_EXTENT,
vectorSimplifyExtent: VECTOR_SIMPLIFY_EXTENT,
analyses: [
{
type: 'source',
id: `${source}_${Date.now()}`,
params: {
query: `SELECT * FROM ${source}`
}
}
],
layers: []
};
}

/**
*
* @param layergroup
* @param options
*/
public async aggregationDataview(layergroup: any, dataview: string, categories?: number) {
const {
metadata: {
dataviews: {
[dataview]: { url }
}
}
} = layergroup;

const parameters = [encodeParameter('api_key', this._credentials.apiKey)];

if (categories) {
const encodedCategories = encodeParameter('categories', categories.toString());
parameters.push(encodedCategories);
}

const getUrl = `${url.https}?${parameters.join('&')}`;
const response = await fetch(getRequest(getUrl));
const dataviewResponse = await response.json();

return dataviewResponse;
}

public async instantiateMap(mapConfig: unknown) {
let response;

try {
const payload = JSON.stringify(mapConfig);
response = await fetch(this.makeMapsApiRequest(payload));
} catch (error) {
throw new Error(
`Failed to connect to Maps API with the user ('${this._credentials.username}'): ${error}`
);
}

const layergroup = (await response.json()) as never;

if (!response.ok) {
this.dealWithWindshaftErrors(response, layergroup);
}

return layergroup;
}

private makeMapsApiRequest(config: string) {
const encodedApiKey = encodeParameter('api_key', this._credentials.apiKey);
const parameters = [encodedApiKey];
const url = this.generateMapsApiUrl(parameters);

const getUrl = `${url}&${encodeParameter('config', config)}`;

if (getUrl.length < REQUEST_GET_MAX_URL_LENGTH) {
return getRequest(getUrl);
}

return postRequest(url, config);
}

private dealWithWindshaftErrors(
response: { status: number },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
layergroup: any
) {
const errorForCode = errorHandlers[response.status];

if (errorForCode) {
errorForCode(this._credentials);
return;
}

throw new Error(`${JSON.stringify(layergroup.errors)}`);
}

private generateMapsApiUrl(parameters: string[] = []) {
const base = `${this._credentials.serverURL}api/v1/map`;
return `${base}?${parameters.join('&')}`;
}
}

export interface AggregationColumn {
// eslint-disable-next-line camelcase
aggregate_function: string;
// eslint-disable-next-line camelcase
aggregated_column: string;
}

export interface StatsColumn {
topCategories: number;
includeNulls: boolean;
}

export interface Sample {
// eslint-disable-next-line camelcase
num_rows: number;
// eslint-disable-next-line camelcase
include_columns: string[];
}

export interface MapOptions {
bufferSize?: BufferSizeOptions;
sql?: string;
dataset?: string;
vectorExtent: number;
vectorSimplifyExtent: number;
metadata?: {
geometryType: boolean;
columnStats?: StatsColumn;
dimensions?: boolean;
sample?: Sample;
};
aggregation?: {
placement: string;
resolution: number;
threshold?: number;
columns?: Record<string, AggregationColumn>;
dimensions?: Record<string, { column: string }>;
};
}

interface BufferSizeOptions {
png?: number;
'grid.json'?: number;
mvt?: number;
}

export interface MapInstance {
layergroupid: string;
// eslint-disable-next-line camelcase
last_updated: string;
metadata: {
layers: [
{
type: string;
id: string;
meta: {
stats: {
estimatedFeatureCount: number;
geometryType: string;
// TODO: create a proper type for columns
// eslint-disable-next-line @typescript-eslint/no-explicit-any
columns: any;
sample: number[];
};
aggregation: {
png: boolean;
mvt: boolean;
};
};
tilejson: {
vector: {
tilejson: string;
tiles: string[];
};
};
}
];
tilejson: {
vector: {
tilejson: string;
tiles: string[];
};
};
url: {
vector: {
urlTemplate: string;
subdomains: string[];
};
};
// eslint-disable-next-line camelcase
cdn_url: {
http: string;
https: string;
templates: {
http: {
subdomains: string[];
url: string;
};
https: {
subdomains: string[];
url: string;
};
};
};
};
}
113 changes: 113 additions & 0 deletions src/lib/maps/MapsDataviews.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Credentials } from '../core/Credentials';
import { Client } from './Client';

export class MapsDataviews {
private _source: string;
private _mapClient: Client;

constructor(source: string, credentials: Credentials) {
this._source = source;
this._mapClient = new Client(credentials);
}

public async aggregation(params: AggregationParameters): Promise<AggregationResponse> {
const { column, aggregation, operationColumn, limit } = params;

const dataviewName = `${this._source}_${Date.now()}`;
const layergroup = await this._createMapWithAggregationDataviews(
dataviewName,
column,
aggregation,
operationColumn
);

const aggregationResponse = this._mapClient.aggregationDataview(
layergroup,
dataviewName,
limit
);

return (aggregationResponse as unknown) as AggregationResponse;
}

private _createMapWithAggregationDataviews(
name: string,
column: string,
aggregation: AggregationType,
operationColumn?: string
) {
const sourceMapConfig = Client.generateMapConfigFromSource(this._source);
const sourceId = sourceMapConfig.analyses[0].id;
const mapConfig = {
...sourceMapConfig,
dataviews: {
[name]: {
type: 'aggregation',
source: { id: sourceId },
options: {
column,
aggregation,
aggregationColumn: operationColumn
}
}
}
};

const response = this._mapClient.instantiateMap(mapConfig);
return response;
}
}

export interface AggregationResponse {
count: number;
max: number;
min: number;
nulls: number;
nans: number;
infinities: number;
aggregation: AggregationType;
categoriesCount: number;
categories: AggregationCategory[];
// eslint-disable-next-line camelcase
errors_with_context?: { type: string; message: string }[];
errors?: string[];
}

export interface AggregationCategory {
agg: boolean;
category: string;
value: number;
}

export interface AggregationParameters {
/**
* column name to aggregate by
*/
column: string;

/**
* operation to perform
*/
aggregation: AggregationType;

/**
* The num of categories
*/
limit?: number;

/**
* Column value to aggregate.
* This param is required when
* `aggregation` is different than "count"
*/
operationColumn?: string;
}

export enum AggregationType {
COUNT = 'count',
AVG = 'avg',
MIN = 'min',
MAX = 'max',
SUM = 'sum',
PERCENTILE = 'percentile'
}
Loading

1 comment on commit 0856c57

@vercel
Copy link

@vercel vercel bot commented on 0856c57 Jun 9, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.