@opengis/bi 1.2.30 → 1.2.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -92
- package/dist/bi.js +1 -1
- package/dist/bi.umd.cjs +88 -86
- package/dist/{import-file-B4o9p2_2.js → import-file-D-ISqB7l.js} +1772 -1593
- package/dist/style.css +1 -1
- package/dist/{vs-funnel-bar-BOWwPnjW.js → vs-funnel-bar-aoZzvriV.js} +3 -3
- package/dist/{vs-list-D8jGusRT.js → vs-list-CBkyJSBj.js} +53 -32
- package/dist/{vs-map-BGplOwpB.js → vs-map-C3C11qmT.js} +102 -91
- package/dist/{vs-map-cluster-CHQJV2As.js → vs-map-cluster-BsPmHIMx.js} +91 -81
- package/dist/{vs-number-jPqxFQ6d.js → vs-number-d58ftpH5.js} +3 -3
- package/dist/{vs-table-BDgIvJbY.js → vs-table-BHa5Velm.js} +6 -6
- package/dist/{vs-text-DcHOffy9.js → vs-text-Bq87gMTx.js} +4 -4
- package/package.json +77 -75
- package/plugin.js +21 -22
- package/server/helpers/mdToHTML.js +17 -17
- package/server/migrations/bi.dataset.sql +46 -46
- package/server/migrations/bi.sql +115 -112
- package/server/plugins/docs.js +48 -48
- package/server/plugins/hook.js +89 -89
- package/server/plugins/vite.js +81 -81
- package/server/routes/dashboard/controllers/dashboard.import.js +103 -103
- package/server/routes/dashboard/controllers/dashboard.js +158 -157
- package/server/routes/dashboard/controllers/dashboard.list.js +60 -60
- package/server/routes/dashboard/controllers/utils/yaml.js +11 -11
- package/server/routes/dashboard/index.mjs +26 -26
- package/server/routes/data/controllers/data.js +230 -229
- package/server/routes/data/controllers/util/chartSQL.js +49 -49
- package/server/routes/data/controllers/util/normalizeData.js +65 -65
- package/server/routes/data/index.mjs +38 -38
- package/server/routes/dataset/controllers/bi.dataset.list.js +29 -29
- package/server/routes/dataset/controllers/bi.db.list.js +19 -19
- package/server/routes/dataset/controllers/comment.js +55 -55
- package/server/routes/dataset/controllers/createDatasetPost.js +134 -134
- package/server/routes/dataset/controllers/data.js +149 -149
- package/server/routes/dataset/controllers/dbTablePreview.js +58 -58
- package/server/routes/dataset/controllers/dbTables.js +34 -34
- package/server/routes/dataset/controllers/delete.js +40 -40
- package/server/routes/dataset/controllers/deleteDataset.js +52 -52
- package/server/routes/dataset/controllers/editDataset.js +90 -90
- package/server/routes/dataset/controllers/export.js +214 -214
- package/server/routes/dataset/controllers/form.js +99 -99
- package/server/routes/dataset/controllers/format.js +46 -46
- package/server/routes/dataset/controllers/insert.js +47 -47
- package/server/routes/dataset/controllers/table.js +68 -68
- package/server/routes/dataset/controllers/update.js +43 -43
- package/server/routes/dataset/index.mjs +132 -132
- package/server/routes/dataset/utils/convertJSONToCSV.js +17 -17
- package/server/routes/dataset/utils/convertJSONToXls.js +47 -47
- package/server/routes/dataset/utils/createTableQuery.js +59 -59
- package/server/routes/dataset/utils/datasetForms.js +1 -1
- package/server/routes/dataset/utils/descriptionList.js +45 -45
- package/server/routes/dataset/utils/downloadRemoteFile.js +58 -58
- package/server/routes/dataset/utils/executeQuery.js +46 -46
- package/server/routes/dataset/utils/getLayersData.js +106 -106
- package/server/routes/dataset/utils/getTableData.js +46 -46
- package/server/routes/dataset/utils/insertDataQuery.js +12 -12
- package/server/routes/dataset/utils/metaFormat.js +24 -24
- package/server/routes/edit/controllers/dashboard.add.js +36 -36
- package/server/routes/edit/controllers/dashboard.delete.js +39 -39
- package/server/routes/edit/controllers/dashboard.edit.js +61 -61
- package/server/routes/edit/controllers/widget.add.js +78 -78
- package/server/routes/edit/controllers/widget.del.js +58 -58
- package/server/routes/edit/controllers/widget.edit.js +115 -115
- package/server/routes/edit/index.mjs +33 -33
- package/server/routes/map/controllers/cluster.js +125 -125
- package/server/routes/map/controllers/clusterVtile.js +166 -166
- package/server/routes/map/controllers/geojson.js +127 -127
- package/server/routes/map/controllers/heatmap.js +118 -118
- package/server/routes/map/controllers/map.js +69 -69
- package/server/routes/map/controllers/utils/downloadClusterData.js +44 -44
- package/server/routes/map/controllers/vtile.js +183 -183
- package/server/routes/map/index.mjs +32 -32
- package/server/templates/page/login.html +58 -58
- package/server/utils/getWidget.js +118 -117
- package/utils.js +12 -12
|
@@ -1,127 +1,127 @@
|
|
|
1
|
-
import path from 'path';
|
|
2
|
-
import { createHash } from 'crypto';
|
|
3
|
-
import { writeFile, mkdir, readFile, stat } from 'fs/promises';
|
|
4
|
-
import { existsSync, /* readdirSync, */ readFileSync } from 'fs';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import { getFolder, getFilterSQL, autoIndex, logger, pgClients } from '@opengis/fastify-table/utils.js';
|
|
8
|
-
|
|
9
|
-
import normalizeData from '../../data/controllers/util/normalizeData.js';
|
|
10
|
-
|
|
11
|
-
import { getWidget } from '../../../../utils.js';
|
|
12
|
-
|
|
13
|
-
const types = {
|
|
14
|
-
point: 'ST_Point' /* ,ST_MultiPoint */,
|
|
15
|
-
polygon: 'ST_Polygon,ST_MultiPolygon',
|
|
16
|
-
};
|
|
17
|
-
const hourMs = 3.6e6;
|
|
18
|
-
|
|
19
|
-
export default async function geojson(req, reply) {
|
|
20
|
-
const { query = {} } = req;
|
|
21
|
-
|
|
22
|
-
const {
|
|
23
|
-
filter,
|
|
24
|
-
widget,
|
|
25
|
-
sql,
|
|
26
|
-
type,
|
|
27
|
-
nocache,
|
|
28
|
-
id,
|
|
29
|
-
dashboard,
|
|
30
|
-
geom = 'geom',
|
|
31
|
-
pointZoom = 0,
|
|
32
|
-
} = query;
|
|
33
|
-
|
|
34
|
-
if (!widget && !dashboard) {
|
|
35
|
-
return { message: 'not enough params: widget', status: 400 };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const data = await getWidget({ pg: req.pg, dashboard, widget });
|
|
39
|
-
if (data.status) return data;
|
|
40
|
-
|
|
41
|
-
const pg = data.pg || req.pg || pgClients.client;
|
|
42
|
-
const hash = [pointZoom, filter].filter((el) => el).join();
|
|
43
|
-
|
|
44
|
-
const root = getFolder(req);
|
|
45
|
-
const file = path.join(
|
|
46
|
-
root,
|
|
47
|
-
`/map/geojson/${widget}/${hash ? `${createHash('sha1').update(hash).digest('base64')}/` : ''}.geojson`
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
if (existsSync(file)) {
|
|
51
|
-
const timeNow = Date.now();
|
|
52
|
-
const stats = await stat(file);
|
|
53
|
-
const birthTime = new Date(stats.birthtime).getTime();
|
|
54
|
-
if (!(birthTime - timeNow > hourMs * 24) && !nocache) {
|
|
55
|
-
const res = JSON.parse((await readFile(file, 'utf-8')) || {});
|
|
56
|
-
return res;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
try {
|
|
61
|
-
const pkey = pg.pk?.[data?.table];
|
|
62
|
-
if (!pkey) {
|
|
63
|
-
return {
|
|
64
|
-
message: `invalid ${widget ? 'widget' : 'dashboard'}: table pk not found (${data?.table})`,
|
|
65
|
-
status: 400,
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// data param
|
|
70
|
-
const { table, where = '1=1', xName, x } = normalizeData(data, query);
|
|
71
|
-
|
|
72
|
-
if (!xName && !x) {
|
|
73
|
-
return {
|
|
74
|
-
message: `invalid ${widget ? 'widget' : 'dashboard'}: x axis column not specified`,
|
|
75
|
-
status: 400,
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// get sql
|
|
80
|
-
const filterQ = filter
|
|
81
|
-
? await getFilterSQL({ pg, table, filter, query })
|
|
82
|
-
: undefined;
|
|
83
|
-
const q = `select "${pkey}", "${xName || x}", /* st_asgeojson(geom)::json as */ ${geom} as geom from ${filterQ ? `(${filterQ})` : table} q where ${where}`;
|
|
84
|
-
|
|
85
|
-
if (sql === '1') return q;
|
|
86
|
-
|
|
87
|
-
const { st_geometrytype: geomType = 'point' } = await pg
|
|
88
|
-
.query(
|
|
89
|
-
`select st_geometrytype(${geom}), count(*) from ${table}
|
|
90
|
-
where ${where} group by st_geometrytype(${geom})`
|
|
91
|
-
)
|
|
92
|
-
.then((res) => res.rows?.[0] || {});
|
|
93
|
-
|
|
94
|
-
const q1 = `SELECT 'FeatureCollection' As type, json_agg(f) As features FROM (
|
|
95
|
-
SELECT 'Feature' As type, row_number() over() as id,
|
|
96
|
-
st_asgeojson(st_force2d(${query.srid
|
|
97
|
-
? `st_transform(${type === 'centroid' ? `st_centroid(${geom})` : geom},${query.srid})`
|
|
98
|
-
: `${type === 'centroid' || query.point || query.centroid ? `st_centroid(${geom})` : geom}`
|
|
99
|
-
}), 6, 0)::json as geometry,
|
|
100
|
-
(select row_to_json(tc) from (select ${'' ? `${''} as status, ` : ''}
|
|
101
|
-
${xName ? `${xName},` : ''}
|
|
102
|
-
${data.style?.colorAttr ? `${data.style.colorAttr},` : ''}
|
|
103
|
-
${pkey} as id,(select file_path from crm.files
|
|
104
|
-
where entity_id=q.${pkey}::text and file_status <>'3' and ext in ('png','jpg') limit 1) as image
|
|
105
|
-
)tc) as properties
|
|
106
|
-
from (${q})q where ${id && pkey ? ` ${pkey} = '${id}' and ` : ''} ${geom} is not null
|
|
107
|
-
${data.query ? ` and ${data.query}` : ''}
|
|
108
|
-
${query.extent ? `and ${geom} && 'BOX(${query.extent.split(',').reduce((p, el, i) => p + el + (i % 2 ? ',' : ' '), '')})'::box2d` : ''}
|
|
109
|
-
${types[type] ? ` and ST_GeometryType(${geom}) = any ('{ ${types[type]} }') ` : ''}
|
|
110
|
-
limit ${geomType?.toLowerCase()?.includes('point') ? '15000' : '2500'})f`;
|
|
111
|
-
|
|
112
|
-
if (sql === '2') return q1;
|
|
113
|
-
|
|
114
|
-
// auto Index
|
|
115
|
-
autoIndex({ table, columns: [xName] });
|
|
116
|
-
|
|
117
|
-
const res = await pg.query(q1).then((res) => res.rows?.[0] || {});
|
|
118
|
-
|
|
119
|
-
await mkdir(path.dirname(file), { recursive: true });
|
|
120
|
-
await writeFile(file, JSON.stringify(res));
|
|
121
|
-
|
|
122
|
-
return res;
|
|
123
|
-
} catch (err) {
|
|
124
|
-
logger.file('bi/geojson', { level: 'ERROR', error: err.toString(), query });
|
|
125
|
-
return { error: err.toString(), status: 500 };
|
|
126
|
-
}
|
|
127
|
-
}
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { writeFile, mkdir, readFile, stat } from 'fs/promises';
|
|
4
|
+
import { existsSync, /* readdirSync, */ readFileSync } from 'fs';
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
import { getFolder, getFilterSQL, autoIndex, logger, pgClients } from '@opengis/fastify-table/utils.js';
|
|
8
|
+
|
|
9
|
+
import normalizeData from '../../data/controllers/util/normalizeData.js';
|
|
10
|
+
|
|
11
|
+
import { getWidget } from '../../../../utils.js';
|
|
12
|
+
|
|
13
|
+
const types = {
|
|
14
|
+
point: 'ST_Point' /* ,ST_MultiPoint */,
|
|
15
|
+
polygon: 'ST_Polygon,ST_MultiPolygon',
|
|
16
|
+
};
|
|
17
|
+
const hourMs = 3.6e6;
|
|
18
|
+
|
|
19
|
+
export default async function geojson(req, reply) {
|
|
20
|
+
const { query = {} } = req;
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
filter,
|
|
24
|
+
widget,
|
|
25
|
+
sql,
|
|
26
|
+
type,
|
|
27
|
+
nocache,
|
|
28
|
+
id,
|
|
29
|
+
dashboard,
|
|
30
|
+
geom = 'geom',
|
|
31
|
+
pointZoom = 0,
|
|
32
|
+
} = query;
|
|
33
|
+
|
|
34
|
+
if (!widget && !dashboard) {
|
|
35
|
+
return { message: 'not enough params: widget', status: 400 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const data = await getWidget({ pg: req.pg, dashboard, widget });
|
|
39
|
+
if (data.status) return data;
|
|
40
|
+
|
|
41
|
+
const pg = data.pg || req.pg || pgClients.client;
|
|
42
|
+
const hash = [pointZoom, filter].filter((el) => el).join();
|
|
43
|
+
|
|
44
|
+
const root = getFolder(req);
|
|
45
|
+
const file = path.join(
|
|
46
|
+
root,
|
|
47
|
+
`/map/geojson/${widget}/${hash ? `${createHash('sha1').update(hash).digest('base64')}/` : ''}.geojson`
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (existsSync(file)) {
|
|
51
|
+
const timeNow = Date.now();
|
|
52
|
+
const stats = await stat(file);
|
|
53
|
+
const birthTime = new Date(stats.birthtime).getTime();
|
|
54
|
+
if (!(birthTime - timeNow > hourMs * 24) && !nocache) {
|
|
55
|
+
const res = JSON.parse((await readFile(file, 'utf-8')) || {});
|
|
56
|
+
return res;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const pkey = pg.pk?.[data?.table];
|
|
62
|
+
if (!pkey) {
|
|
63
|
+
return {
|
|
64
|
+
message: `invalid ${widget ? 'widget' : 'dashboard'}: table pk not found (${data?.table})`,
|
|
65
|
+
status: 400,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// data param
|
|
70
|
+
const { table, where = '1=1', xName, x } = normalizeData(data, query);
|
|
71
|
+
|
|
72
|
+
if (!xName && !x) {
|
|
73
|
+
return {
|
|
74
|
+
message: `invalid ${widget ? 'widget' : 'dashboard'}: x axis column not specified`,
|
|
75
|
+
status: 400,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// get sql
|
|
80
|
+
const filterQ = filter
|
|
81
|
+
? await getFilterSQL({ pg, table, filter, query })
|
|
82
|
+
: undefined;
|
|
83
|
+
const q = `select "${pkey}", "${xName || x}", /* st_asgeojson(geom)::json as */ ${geom} as geom from ${filterQ ? `(${filterQ})` : table} q where ${where}`;
|
|
84
|
+
|
|
85
|
+
if (sql === '1') return q;
|
|
86
|
+
|
|
87
|
+
const { st_geometrytype: geomType = 'point' } = await pg
|
|
88
|
+
.query(
|
|
89
|
+
`select st_geometrytype(${geom}), count(*) from ${table}
|
|
90
|
+
where ${where} group by st_geometrytype(${geom})`
|
|
91
|
+
)
|
|
92
|
+
.then((res) => res.rows?.[0] || {});
|
|
93
|
+
|
|
94
|
+
const q1 = `SELECT 'FeatureCollection' As type, json_agg(f) As features FROM (
|
|
95
|
+
SELECT 'Feature' As type, row_number() over() as id,
|
|
96
|
+
st_asgeojson(st_force2d(${query.srid
|
|
97
|
+
? `st_transform(${type === 'centroid' ? `st_centroid(${geom})` : geom},${query.srid})`
|
|
98
|
+
: `${type === 'centroid' || query.point || query.centroid ? `st_centroid(${geom})` : geom}`
|
|
99
|
+
}), 6, 0)::json as geometry,
|
|
100
|
+
(select row_to_json(tc) from (select ${'' ? `${''} as status, ` : ''}
|
|
101
|
+
${xName ? `${xName},` : ''}
|
|
102
|
+
${data.style?.colorAttr ? `${data.style.colorAttr},` : ''}
|
|
103
|
+
${pkey} as id,(select file_path from crm.files
|
|
104
|
+
where entity_id=q.${pkey}::text and file_status <>'3' and ext in ('png','jpg') limit 1) as image
|
|
105
|
+
)tc) as properties
|
|
106
|
+
from (${q})q where ${id && pkey ? ` ${pkey} = '${id}' and ` : ''} ${geom} is not null
|
|
107
|
+
${data.query ? ` and ${data.query}` : ''}
|
|
108
|
+
${query.extent ? `and ${geom} && 'BOX(${query.extent.split(',').reduce((p, el, i) => p + el + (i % 2 ? ',' : ' '), '')})'::box2d` : ''}
|
|
109
|
+
${types[type] ? ` and ST_GeometryType(${geom}) = any ('{ ${types[type]} }') ` : ''}
|
|
110
|
+
limit ${geomType?.toLowerCase()?.includes('point') ? '15000' : '2500'})f`;
|
|
111
|
+
|
|
112
|
+
if (sql === '2') return q1;
|
|
113
|
+
|
|
114
|
+
// auto Index
|
|
115
|
+
autoIndex({ table, columns: [xName] });
|
|
116
|
+
|
|
117
|
+
const res = await pg.query(q1).then((res) => res.rows?.[0] || {});
|
|
118
|
+
|
|
119
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
120
|
+
await writeFile(file, JSON.stringify(res));
|
|
121
|
+
|
|
122
|
+
return res;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
logger.file('bi/geojson', { level: 'ERROR', error: err.toString(), query });
|
|
125
|
+
return { error: err.toString(), status: 500 };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -1,118 +1,118 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
4
|
-
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
|
5
|
-
|
|
6
|
-
import { pgClients, getFilterSQL, getMeta, getFolder } from '@opengis/fastify-table/utils.js';
|
|
7
|
-
|
|
8
|
-
import { getWidget } from '../../../../utils.js';
|
|
9
|
-
|
|
10
|
-
const hourMs = 3.6e6;
|
|
11
|
-
const maxLimit = 2500;
|
|
12
|
-
|
|
13
|
-
export default async function heatmap(req, reply) {
|
|
14
|
-
const { query = {}, user = {} } = req;
|
|
15
|
-
|
|
16
|
-
const { widget, dashboard, filter, search, size = 0.1 } = query;
|
|
17
|
-
|
|
18
|
-
if (query.size && (+query.size || 0) <= 0) {
|
|
19
|
-
return { message: 'param size is invalid', status: 400 };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
if (!dashboard || !widget) {
|
|
23
|
-
return { message: 'not enough params: dashboard / widget', status: 400 };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const { data } = await getWidget({ pg: req.pg, widget, dashboard });
|
|
27
|
-
|
|
28
|
-
if (!data?.table) {
|
|
29
|
-
return { message: 'widget not found: ' + widget, status: 400 };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const limit = Math.min(+query.limit || maxLimit, maxLimit);
|
|
33
|
-
const hash = [search, filter, limit].filter((el) => el).join();
|
|
34
|
-
|
|
35
|
-
const root = getFolder(req, 'local');
|
|
36
|
-
const file = path.join(
|
|
37
|
-
root,
|
|
38
|
-
`/map/geojson/heatmap/${data.table}/${hash ? `${createHash('sha1').update(hash).digest('base64')}/` : ''}.geojson`
|
|
39
|
-
);
|
|
40
|
-
|
|
41
|
-
if (existsSync(file) && !query.nocache && !query.sql) {
|
|
42
|
-
const timeNow = Date.now();
|
|
43
|
-
const stats = await stat(file);
|
|
44
|
-
const birthTime = new Date(stats.birthtime).getTime();
|
|
45
|
-
if (!(birthTime - timeNow > hourMs * 24)) {
|
|
46
|
-
const geojson = JSON.parse((await readFile(file, 'utf-8')) || {});
|
|
47
|
-
return geojson;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const pg = data.pg || req.pg || pgClients.client;
|
|
52
|
-
|
|
53
|
-
if (!pg.pk?.[data.table]) {
|
|
54
|
-
return { message: `table not found: ${data.table}`, status: 404 };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const metric = query.metric || data.metrics?.[0]?.name || (Array.isArray(data.metrics) ? data.metrics?.[0] : data.metrics);
|
|
58
|
-
|
|
59
|
-
const operator = metric
|
|
60
|
-
? (['sum', 'min', 'max', 'avg'].find(el => el === query.operator) || 'sum')
|
|
61
|
-
: undefined;
|
|
62
|
-
|
|
63
|
-
const aggregator = metric
|
|
64
|
-
? `${operator}(${metric})`
|
|
65
|
-
: 'count(*)';
|
|
66
|
-
|
|
67
|
-
const { geom, columns } = await getMeta({ pg, table: data.table });
|
|
68
|
-
|
|
69
|
-
const { dataTypeID } = columns.find(col => col.name === metric) || {};
|
|
70
|
-
|
|
71
|
-
if (metric && !dataTypeID) {
|
|
72
|
-
return { message: `metric column not found: ${metric}`, status: 404 };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (!['integer', 'numeric', 'double precision'].includes(pg.pgType[dataTypeID])) {
|
|
76
|
-
return { message: `metric column invalid type: ${metric} (${pg.pgType[dataTypeID]})`, status: 404 };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (!geom) {
|
|
80
|
-
return { message: `geometry column not found: ${data.table}`, status: 404 };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const { optimizedSQL = `select * from ${data.table} where 1=1` } = hash ? await getFilterSQL({ pg, table: data.table, filter, search }) : {};
|
|
84
|
-
|
|
85
|
-
const subQuery = `SELECT ${aggregator} AS metric, hex.geom FROM (
|
|
86
|
-
SELECT ST_SetSRID( (ST_HexagonGrid(${size}, ST_Extent(q.${geom})) ).geom, 4326 ) as geom FROM ( ${optimizedSQL})q
|
|
87
|
-
)hex
|
|
88
|
-
LEFT JOIN ( ${optimizedSQL} )pts
|
|
89
|
-
ON ST_Within(pts.${geom}, hex.geom)
|
|
90
|
-
|
|
91
|
-
JOIN ( SELECT ST_ConvexHull(ST_Collect(${geom})) AS mask FROM ( ${optimizedSQL} )q )point_mask
|
|
92
|
-
ON ST_Intersects(hex.geom, point_mask.mask)
|
|
93
|
-
|
|
94
|
-
WHERE 1=1 /*and pts.${geom} is not null AND st_srid(pts.${geom}) > 0*/
|
|
95
|
-
GROUP BY hex.geom
|
|
96
|
-
limit ${limit}`;
|
|
97
|
-
|
|
98
|
-
if (query.sql === '1' && user?.user_type?.includes('admin')) return subQuery;
|
|
99
|
-
|
|
100
|
-
const q = `SELECT 'FeatureCollection' As type, json_agg(f) As features FROM (
|
|
101
|
-
SELECT
|
|
102
|
-
'Feature' As type,
|
|
103
|
-
row_number() over() as id,
|
|
104
|
-
st_asgeojson(geom, 6, 0)::json as geometry,
|
|
105
|
-
json_build_object( 'metric', metric ) as properties
|
|
106
|
-
from (${subQuery})sq
|
|
107
|
-
)f`;
|
|
108
|
-
|
|
109
|
-
if (query.sql === '2' && user?.user_type?.includes('admin')) return q;
|
|
110
|
-
|
|
111
|
-
const geojson = await pg.query(q)
|
|
112
|
-
.then(el => el.rows?.[0] || {});
|
|
113
|
-
|
|
114
|
-
await mkdir(path.dirname(file), { recursive: true });
|
|
115
|
-
await writeFile(file, JSON.stringify(geojson));
|
|
116
|
-
|
|
117
|
-
return geojson;
|
|
118
|
-
}
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
|
5
|
+
|
|
6
|
+
import { pgClients, getFilterSQL, getMeta, getFolder } from '@opengis/fastify-table/utils.js';
|
|
7
|
+
|
|
8
|
+
import { getWidget } from '../../../../utils.js';
|
|
9
|
+
|
|
10
|
+
const hourMs = 3.6e6;
|
|
11
|
+
const maxLimit = 2500;
|
|
12
|
+
|
|
13
|
+
export default async function heatmap(req, reply) {
|
|
14
|
+
const { query = {}, user = {} } = req;
|
|
15
|
+
|
|
16
|
+
const { widget, dashboard, filter, search, size = 0.1 } = query;
|
|
17
|
+
|
|
18
|
+
if (query.size && (+query.size || 0) <= 0) {
|
|
19
|
+
return { message: 'param size is invalid', status: 400 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!dashboard || !widget) {
|
|
23
|
+
return { message: 'not enough params: dashboard / widget', status: 400 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { data } = await getWidget({ pg: req.pg, widget, dashboard });
|
|
27
|
+
|
|
28
|
+
if (!data?.table) {
|
|
29
|
+
return { message: 'widget not found: ' + widget, status: 400 };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const limit = Math.min(+query.limit || maxLimit, maxLimit);
|
|
33
|
+
const hash = [search, filter, limit].filter((el) => el).join();
|
|
34
|
+
|
|
35
|
+
const root = getFolder(req, 'local');
|
|
36
|
+
const file = path.join(
|
|
37
|
+
root,
|
|
38
|
+
`/map/geojson/heatmap/${data.table}/${hash ? `${createHash('sha1').update(hash).digest('base64')}/` : ''}.geojson`
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
if (existsSync(file) && !query.nocache && !query.sql) {
|
|
42
|
+
const timeNow = Date.now();
|
|
43
|
+
const stats = await stat(file);
|
|
44
|
+
const birthTime = new Date(stats.birthtime).getTime();
|
|
45
|
+
if (!(birthTime - timeNow > hourMs * 24)) {
|
|
46
|
+
const geojson = JSON.parse((await readFile(file, 'utf-8')) || {});
|
|
47
|
+
return geojson;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const pg = data.pg || req.pg || pgClients.client;
|
|
52
|
+
|
|
53
|
+
if (!pg.pk?.[data.table]) {
|
|
54
|
+
return { message: `table not found: ${data.table}`, status: 404 };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const metric = query.metric || data.metrics?.[0]?.name || (Array.isArray(data.metrics) ? data.metrics?.[0] : data.metrics);
|
|
58
|
+
|
|
59
|
+
const operator = metric
|
|
60
|
+
? (['sum', 'min', 'max', 'avg'].find(el => el === query.operator) || 'sum')
|
|
61
|
+
: undefined;
|
|
62
|
+
|
|
63
|
+
const aggregator = metric
|
|
64
|
+
? `${operator}(${metric})`
|
|
65
|
+
: 'count(*)';
|
|
66
|
+
|
|
67
|
+
const { geom, columns } = await getMeta({ pg, table: data.table });
|
|
68
|
+
|
|
69
|
+
const { dataTypeID } = columns.find(col => col.name === metric) || {};
|
|
70
|
+
|
|
71
|
+
if (metric && !dataTypeID) {
|
|
72
|
+
return { message: `metric column not found: ${metric}`, status: 404 };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!['integer', 'numeric', 'double precision'].includes(pg.pgType[dataTypeID])) {
|
|
76
|
+
return { message: `metric column invalid type: ${metric} (${pg.pgType[dataTypeID]})`, status: 404 };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!geom) {
|
|
80
|
+
return { message: `geometry column not found: ${data.table}`, status: 404 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const { optimizedSQL = `select * from ${data.table} where 1=1` } = hash ? await getFilterSQL({ pg, table: data.table, filter, search }) : {};
|
|
84
|
+
|
|
85
|
+
const subQuery = `SELECT ${aggregator} AS metric, hex.geom FROM (
|
|
86
|
+
SELECT ST_SetSRID( (ST_HexagonGrid(${size}, ST_Extent(q.${geom})) ).geom, 4326 ) as geom FROM ( ${optimizedSQL})q
|
|
87
|
+
)hex
|
|
88
|
+
LEFT JOIN ( ${optimizedSQL} )pts
|
|
89
|
+
ON ST_Within(pts.${geom}, hex.geom)
|
|
90
|
+
|
|
91
|
+
JOIN ( SELECT ST_ConvexHull(ST_Collect(${geom})) AS mask FROM ( ${optimizedSQL} )q )point_mask
|
|
92
|
+
ON ST_Intersects(hex.geom, point_mask.mask)
|
|
93
|
+
|
|
94
|
+
WHERE 1=1 /*and pts.${geom} is not null AND st_srid(pts.${geom}) > 0*/
|
|
95
|
+
GROUP BY hex.geom
|
|
96
|
+
limit ${limit}`;
|
|
97
|
+
|
|
98
|
+
if (query.sql === '1' && user?.user_type?.includes('admin')) return subQuery;
|
|
99
|
+
|
|
100
|
+
const q = `SELECT 'FeatureCollection' As type, json_agg(f) As features FROM (
|
|
101
|
+
SELECT
|
|
102
|
+
'Feature' As type,
|
|
103
|
+
row_number() over() as id,
|
|
104
|
+
st_asgeojson(geom, 6, 0)::json as geometry,
|
|
105
|
+
json_build_object( 'metric', metric ) as properties
|
|
106
|
+
from (${subQuery})sq
|
|
107
|
+
)f`;
|
|
108
|
+
|
|
109
|
+
if (query.sql === '2' && user?.user_type?.includes('admin')) return q;
|
|
110
|
+
|
|
111
|
+
const geojson = await pg.query(q)
|
|
112
|
+
.then(el => el.rows?.[0] || {});
|
|
113
|
+
|
|
114
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
115
|
+
await writeFile(file, JSON.stringify(geojson));
|
|
116
|
+
|
|
117
|
+
return geojson;
|
|
118
|
+
}
|
|
@@ -1,69 +1,69 @@
|
|
|
1
|
-
import { pgClients, getFilterSQL, getSelectVal } from '@opengis/fastify-table/utils.js';
|
|
2
|
-
|
|
3
|
-
import { getWidget } from '../../../../utils.js';
|
|
4
|
-
|
|
5
|
-
export default async function map(req) {
|
|
6
|
-
const { query = {} } = req;
|
|
7
|
-
const { dashboard, widget } = query;
|
|
8
|
-
|
|
9
|
-
const { pg = req.pg || pgClients.client, data, type, layers, style, controls } = await getWidget({ pg: req.pg, dashboard, widget });
|
|
10
|
-
|
|
11
|
-
if (!['map'].includes(type)) {
|
|
12
|
-
return { message: 'access restricted: invalid widget type', status: 403 };
|
|
13
|
-
}
|
|
14
|
-
if (!data?.table) {
|
|
15
|
-
return { message: 'invalid widget: param table is required', status: 400 };
|
|
16
|
-
}
|
|
17
|
-
if (!pg.pk[data?.table]) {
|
|
18
|
-
return { message: 'invalid widget: table pkey not found', status: 400 };
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const { q = '' } = await getFilterSQL({
|
|
22
|
-
pg,
|
|
23
|
-
table: data?.table,
|
|
24
|
-
filter: query.filter,
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
const res = {};
|
|
28
|
-
if (data?.color) {
|
|
29
|
-
const { rows = [] } = await pg.query(
|
|
30
|
-
`select count(*), "${data.color}" as val from ${data.table} where ${data.query || '1=1'} group by "${data.color}"`
|
|
31
|
-
);
|
|
32
|
-
if (data?.cls) {
|
|
33
|
-
const vals = await getSelectVal({
|
|
34
|
-
pg, name: data.cls, values: rows.map(el => el.val), ar: true,
|
|
35
|
-
});
|
|
36
|
-
rows.forEach(row => Object.assign(row, { ...vals?.find?.(el => el.id === row.val) || { text: row.val } }));
|
|
37
|
-
}
|
|
38
|
-
Object.assign(res, { colors: rows }); // кольори для легенди
|
|
39
|
-
}
|
|
40
|
-
if (data?.metrics?.length) {
|
|
41
|
-
const metric = data?.metrics[0];
|
|
42
|
-
const q1 = `select PERCENTILE_CONT(0) WITHIN GROUP (ORDER BY "${metric}") as "0",
|
|
43
|
-
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY "${metric}") as "25",
|
|
44
|
-
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY "${metric}") as "50",
|
|
45
|
-
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY "${metric}") as "75",
|
|
46
|
-
PERCENTILE_CONT(1) WITHIN GROUP (ORDER BY "${metric}") as "100" from ${data.table} where ${data.query || '1=1'} and ${q || '1=1'}`;
|
|
47
|
-
const sizes = await pg
|
|
48
|
-
.query(q1)
|
|
49
|
-
.then(el => Object.values(el.rows?.[0] || {}));
|
|
50
|
-
Object.assign(res, { sizes }); // розміри для легенди
|
|
51
|
-
}
|
|
52
|
-
const { bounds, extentStr } = await pg.query(`select count(*),
|
|
53
|
-
st_asgeojson(st_extent(geom))::json as bounds,
|
|
54
|
-
replace(regexp_replace(st_extent(geom)::box2d::text,'BOX\\(|\\)','','g'),' ',',') as "extentStr"
|
|
55
|
-
from ${data.table} where ${data.query || '1=1'}`).then(el => el.rows?.[0] || {});
|
|
56
|
-
const extent = extentStr ? extentStr.split(',') : undefined;
|
|
57
|
-
|
|
58
|
-
Object.assign(res, {
|
|
59
|
-
layers,
|
|
60
|
-
style,
|
|
61
|
-
controls,
|
|
62
|
-
columns: data.columns,
|
|
63
|
-
bounds, // Map bounds
|
|
64
|
-
extent,
|
|
65
|
-
top: [], // 10 найкращих
|
|
66
|
-
bottom: [], // 10 найгірших
|
|
67
|
-
});
|
|
68
|
-
return res;
|
|
69
|
-
}
|
|
1
|
+
import { pgClients, getFilterSQL, getSelectVal } from '@opengis/fastify-table/utils.js';
|
|
2
|
+
|
|
3
|
+
import { getWidget } from '../../../../utils.js';
|
|
4
|
+
|
|
5
|
+
export default async function map(req) {
|
|
6
|
+
const { query = {} } = req;
|
|
7
|
+
const { dashboard, widget } = query;
|
|
8
|
+
|
|
9
|
+
const { pg = req.pg || pgClients.client, data, type, layers, style, controls } = await getWidget({ pg: req.pg, dashboard, widget });
|
|
10
|
+
|
|
11
|
+
if (!['map'].includes(type)) {
|
|
12
|
+
return { message: 'access restricted: invalid widget type', status: 403 };
|
|
13
|
+
}
|
|
14
|
+
if (!data?.table) {
|
|
15
|
+
return { message: 'invalid widget: param table is required', status: 400 };
|
|
16
|
+
}
|
|
17
|
+
if (!pg.pk[data?.table]) {
|
|
18
|
+
return { message: 'invalid widget: table pkey not found', status: 400 };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { q = '' } = await getFilterSQL({
|
|
22
|
+
pg,
|
|
23
|
+
table: data?.table,
|
|
24
|
+
filter: query.filter,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const res = {};
|
|
28
|
+
if (data?.color) {
|
|
29
|
+
const { rows = [] } = await pg.query(
|
|
30
|
+
`select count(*), "${data.color}" as val from ${data.table} where ${data.query || '1=1'} group by "${data.color}"`
|
|
31
|
+
);
|
|
32
|
+
if (data?.cls) {
|
|
33
|
+
const vals = await getSelectVal({
|
|
34
|
+
pg, name: data.cls, values: rows.map(el => el.val), ar: true,
|
|
35
|
+
});
|
|
36
|
+
rows.forEach(row => Object.assign(row, { ...vals?.find?.(el => el.id === row.val) || { text: row.val } }));
|
|
37
|
+
}
|
|
38
|
+
Object.assign(res, { colors: rows }); // кольори для легенди
|
|
39
|
+
}
|
|
40
|
+
if (data?.metrics?.length) {
|
|
41
|
+
const metric = data?.metrics[0];
|
|
42
|
+
const q1 = `select PERCENTILE_CONT(0) WITHIN GROUP (ORDER BY "${metric}") as "0",
|
|
43
|
+
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY "${metric}") as "25",
|
|
44
|
+
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY "${metric}") as "50",
|
|
45
|
+
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY "${metric}") as "75",
|
|
46
|
+
PERCENTILE_CONT(1) WITHIN GROUP (ORDER BY "${metric}") as "100" from ${data.table} where ${data.query || '1=1'} and ${q || '1=1'}`;
|
|
47
|
+
const sizes = await pg
|
|
48
|
+
.query(q1)
|
|
49
|
+
.then(el => Object.values(el.rows?.[0] || {}));
|
|
50
|
+
Object.assign(res, { sizes }); // розміри для легенди
|
|
51
|
+
}
|
|
52
|
+
const { bounds, extentStr } = await pg.query(`select count(*),
|
|
53
|
+
st_asgeojson(st_extent(geom))::json as bounds,
|
|
54
|
+
replace(regexp_replace(st_extent(geom)::box2d::text,'BOX\\(|\\)','','g'),' ',',') as "extentStr"
|
|
55
|
+
from ${data.table} where ${data.query || '1=1'}`).then(el => el.rows?.[0] || {});
|
|
56
|
+
const extent = extentStr ? extentStr.split(',') : undefined;
|
|
57
|
+
|
|
58
|
+
Object.assign(res, {
|
|
59
|
+
layers,
|
|
60
|
+
style,
|
|
61
|
+
controls,
|
|
62
|
+
columns: data.columns,
|
|
63
|
+
bounds, // Map bounds
|
|
64
|
+
extent,
|
|
65
|
+
top: [], // 10 найкращих
|
|
66
|
+
bottom: [], // 10 найгірших
|
|
67
|
+
});
|
|
68
|
+
return res;
|
|
69
|
+
}
|