/* eslint-disable */
/**
* vjmap Node.js SDK(单文件版)。
*
* 要求:Node.js ≥ 18(使用内置 fetch / FormData / Blob)。零外部依赖。
*
*
* 使用:
*
* const { Vjmap } = require('./vjmap');
* const svc = new Vjmap('http://127.0.0.1:27660/api/v1', '<jwt-token>');
* console.log(await svc.heart());
* console.log(await svc.version());
*
* 设计:
* - 方法名 camelCase,与官方 JS SDK(vjmap 包的 Service)对齐;参数名也对齐。
* - HTTP 非 2xx → VjmapError { status, url, body }。
* - web服务 {code,msg,data} 响应自动 unwrap 成 data;图形服务 JSON 原样返回。
*/
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
// ---------------------------------------------------------------------------
// 工具函数
// ---------------------------------------------------------------------------
/** 32 位小写 MD5 hex。 */
function md5Hex(input) {
return crypto.createHash('md5').update(input).digest('hex');
}
/**
* 明文密码 → 16 位 secretKey。
* 规则:连续 4 次 md5 后取前 16;空串 → 空串;`ak` 开头 18 位 → 原样返回。
*/
function computeSecretKey(pwd) {
if (!pwd) return '';
if (pwd.length === 18 && pwd.slice(0, 2) === 'ak') return pwd;
let s = pwd;
for (let i = 0; i < 4; i++) s = md5Hex(s);
return s.slice(0, 16);
}
/** 流式计算文件 MD5。支持路径/Buffer/Readable。 */
async function fileMd5(src) {
if (Buffer.isBuffer(src)) return md5Hex(src);
if (typeof src === 'string') {
return await new Promise((resolve, reject) => {
const hash = crypto.createHash('md5');
const stream = fs.createReadStream(src);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
if (src && typeof src.on === 'function') {
return await new Promise((resolve, reject) => {
const hash = crypto.createHash('md5');
src.on('data', (chunk) => hash.update(chunk));
src.on('end', () => resolve(hash.digest('hex')));
src.on('error', reject);
});
}
throw new TypeError('fileMd5: unsupported source type');
}
function normalizeBase(base) {
if (!base) throw new Error('base_url is required');
return base.endsWith('/') ? base : base + '/';
}
/** 简化版 URL-encode query(数字/布尔转字符串,undefined/null 略过)。 */
function encodeQuery(params) {
const parts = [];
for (const [k, v] of Object.entries(params || {})) {
if (v === undefined || v === null) continue;
const val = typeof v === 'boolean' ? String(v) : String(v);
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(val)}`);
}
return parts.join('&');
}
// ---------------------------------------------------------------------------
// 异常
// ---------------------------------------------------------------------------
class VjmapError extends Error {
constructor(message, { status = null, url = null, body = null } = {}) {
super(message);
this.name = 'VjmapError';
this.status = status;
this.url = url;
this.body = body;
}
}
// ---------------------------------------------------------------------------
// MapOpenWay 常量(仅客户端枚举;后端不读,openMap 换算成 body 布尔 geom = mapopenway !== 'Memory')
// ---------------------------------------------------------------------------
const MapOpenWay = Object.freeze({
Memory: 'Memory',
GeomRender: 'GeomRender',
});
// ---------------------------------------------------------------------------
// Vjmap 主类
// ---------------------------------------------------------------------------
class Vjmap {
/**
* @param {string} baseUrl 例如 'http://127.0.0.1:27660/api/v1'
* @param {string} token JWT Token
* @param {object} [opts]
* @param {number} [opts.timeout=60000] 请求超时 (ms)
*/
constructor(baseUrl, token = '', opts = {}) {
this.baseUrl = normalizeBase(baseUrl);
this.token = token || '';
this.timeout = opts.timeout || 60000;
this._curMap = { mapid: '', version: '', layer: '', fileid: '' };
// 缓存的图密钥:发请求时按形态拆头——18 位 `ak` 开头 → `accessKey` 头,其余 → `secretKey` 头
this._secretKeys = [];
// 始终走 `accessKey` 头的密钥(如服务端 config `map_super_key`,后端只在 accessKey 列表里认它)
this._accessKeys = [];
this._workspace = '';
}
// --- 上下文 -----------------------------------------------------------
get workspace() {
return this._workspace;
}
switchWorkspace(name = '') {
this._workspace = name || '';
}
currentMapParam() {
return { ...this._curMap };
}
setCurrentMap({ mapid, version = 'v1', layer = '', fileid = '' }) {
this._curMap = {
mapid: mapid || '',
version: version || 'v1',
layer: layer || '',
fileid: fileid || mapid || '',
};
}
// --- URL 构造 ---------------------------------------------------------
_mapPrefix() {
if (this._workspace) return `workspace/${encodeURIComponent(this._workspace)}/`;
return 'map/';
}
serviceUrl(subPath, params) {
const q = { ...(params || {}), token: (params && params.token) || this.token };
const qs = encodeQuery(q);
return `${this.baseUrl}service/${String(subPath).replace(/^\//, '')}${qs ? '?' + qs : ''}`;
}
mapUrl(subPath, params) {
const q = { ...(params || {}), token: (params && params.token) || this.token };
const qs = encodeQuery(q);
return `${this.baseUrl}${this._mapPrefix()}${String(subPath).replace(/^\//, '')}${qs ? '?' + qs : ''}`;
}
baseGateway() {
const u = new URL(this.baseUrl);
// 仅去掉末尾 /api/vN 段,保留部署前缀(公有云是 /server,私有化是 /)
const p = u.pathname.replace(/\/api\/v\d+\/?$/i, "/");
return `${u.protocol}//${u.host}${p.startsWith("/") ? p : "/" + p}`;
}
gatewayUrl(subPath, params) {
const q = { ...(params || {}), token: (params && params.token) || this.token };
const qs = encodeQuery(q);
return `${this.baseGateway()}${String(subPath).replace(/^\//, '')}${qs ? '?' + qs : ''}`;
}
/** /api/v1/map/cmd/{cmd}/{mapid}/{version}?... */
cmdUrl(cmd, mapid, version, params) {
mapid = mapid || this._curMap.mapid || '_';
version = version || this._curMap.version || '_';
const sub = `cmd/${encodeURIComponent(cmd)}/${encodeURIComponent(mapid)}/${encodeURIComponent(version)}`;
return this.mapUrl(sub, params);
}
// --- HTTP 核心 --------------------------------------------------------
async _extractBody(resp) {
const ctype = (resp.headers.get('content-type') || '').toLowerCase();
if (ctype.includes('application/json')) {
try {
return await resp.json();
} catch (_) {
return await resp.text();
}
}
if (ctype.startsWith('text/')) return await resp.text();
// 图形引擎部分响应 content-type 不规范(octet-stream 里实为 JSON),按首字符嗅探
const buf = Buffer.from(await resp.arrayBuffer());
const head = buf.subarray(0, 64).toString('utf8').trimStart();
if (head.startsWith('{') || head.startsWith('[')) {
try { return JSON.parse(buf.toString('utf8')); } catch (_) { /* 真二进制 */ }
}
return buf;
}
_raiseIfError(resp, body) {
if (resp.ok) return;
throw new VjmapError(`HTTP ${resp.status} for ${resp.url}`, {
status: resp.status,
url: resp.url,
body,
});
}
_unwrapGateway(body) {
if (body === null || typeof body !== 'object' || Array.isArray(body)) return body;
if ('code' in body && 'data' in body && 'msg' in body) {
if (body.code === 0) return body.data;
throw new VjmapError(`gateway code=${body.code}: ${body.msg}`, { status: 200, body });
}
return body;
}
/**
* 由缓存密钥组装 `secretKey` / `accessKey` 请求头(多个用 `_;_` 连接)。
* 后端 HavePermissionInMap:accessKey 列表含 map_super_key → admin;secretKey 列表含该图
* secretKey → admin;accessKey 列表含该图 accessKey → 仅浏览。头部同名键会**覆盖** body 里的值。
*/
_keyHeaders() {
const isAk = (k) => typeof k === 'string' && k.length === 18 && k.startsWith('ak');
const sk = this._secretKeys.filter((k) => k && !isAk(k));
const ak = [...this._secretKeys.filter(isAk), ...this._accessKeys.filter(Boolean)];
const h = {};
if (sk.length) h.secretKey = sk.join('_;_');
if (ak.length) h.accessKey = ak.join('_;_');
return h;
}
async _request(method, url, opts = {}) {
const { jsonBody, form, headers, timeout, stream } = opts;
const hdrs = {};
if (this.token) hdrs['token'] = this.token;
Object.assign(hdrs, this._keyHeaders());
let body;
if (jsonBody !== undefined) {
hdrs['Content-Type'] = 'application/json';
body = JSON.stringify(jsonBody);
} else if (form) {
body = form; // FormData instance
}
if (headers) Object.assign(hdrs, headers);
const ctrl = new AbortController();
const to = setTimeout(() => ctrl.abort(), timeout || this.timeout);
let resp;
try {
resp = await fetch(url, {
method: method.toUpperCase(),
headers: hdrs,
body,
signal: ctrl.signal,
});
} catch (err) {
clearTimeout(to);
throw new VjmapError(String(err && err.message || err), { url });
}
clearTimeout(to);
if (stream) return resp; // 调用方自己处理流
const parsed = await this._extractBody(resp);
this._raiseIfError(resp, parsed);
return this._unwrapGateway(parsed);
}
async heart() {
return await this._request('GET', this.gatewayUrl('heart', {}));
}
async version() {
return await this._request('GET', this.gatewayUrl('version', {}));
}
async runStatus(detail = true) {
return await this._request(
'GET',
this.cmdUrl('runstatus', '_', 'v1', { detail }),
);
}
async viewLogs(record = 100) {
return await this._request(
'GET',
this.cmdUrl('viewlogs', '_', 'v1', { record }),
);
}
// =========================================================================
// 分类 1:地图管理
// =========================================================================
async checkMapFile(md5) {
return await this._request('GET', this.mapUrl('mapfile', { md5 }));
}
/**
* 上传地图源文件(multipart)。
* @param {string|Buffer|{filename:string, content:Buffer}} fileArg
* @param {object} [opts]
* @param {string} [opts.filename]
* @param {string} [opts.md5]
* @param {object} [opts.extraFields]
* @returns {Promise<{fileid:string, mapid:string, uploadname:string, version?:string, geom?:boolean, maptype?:string, error?:string}>}
* 后端只取 multipart 里的文件部分(md5/extraFields 等表单字段不读),按内容自算 md5 命名。
*/
async uploadMap(fileArg, opts = {}) {
let name, content;
if (typeof fileArg === 'string') {
name = opts.filename || path.basename(fileArg);
content = fs.readFileSync(fileArg);
} else if (Buffer.isBuffer(fileArg)) {
if (!opts.filename) throw new Error('uploadMap: Buffer 模式必须传 opts.filename');
name = opts.filename;
content = fileArg;
} else if (fileArg && fileArg.content) {
name = fileArg.filename || opts.filename || 'upload.bin';
content = fileArg.content;
} else {
throw new TypeError('uploadMap: fileArg 必须是路径/Buffer/{filename,content}');
}
const md5 = opts.md5 || md5Hex(content);
const fd = new FormData();
fd.append('md5', md5);
if (opts.extraFields) {
for (const [k, v] of Object.entries(opts.extraFields)) {
fd.append(k, String(v));
}
}
fd.append('file', new Blob([content], { type: 'application/octet-stream' }), name);
return await this._request('POST', this.mapUrl('uploads', {}), { form: fd });
}
/**
* 打开地图。
* 后端不读 `mapopenway`,只认布尔 `geom`(缺省 false=内存模式);SDK 按官方 JS SDK 的做法换算
* `geom = mapopenway !== 'Memory'`。GeomRender 首次打开是异步的:响应 `{status:"working"}`,
* 需 `waitMapOpenFinish()` 轮询到 finish 后再打开一次才有完整参数。
* @param {object} args
* @param {string} args.mapid
* @param {string} [args.version='v1']
* @param {'Memory'|'GeomRender'} [args.mapopenway='GeomRender']
* @param {string} [args.fileid]
* @param {object} [args.style]
* @param {string} [args.secretKey] 显式给出时本次请求的 `secretKey` 头只带它(头会覆盖 body 同名键)
* @param {string} [args.accessKey] 同上,`accessKey` 头只带它
* @param {any} [args.mapbounds]
* @param {any} [args.mapLayerIndex]
* @param {any} [args.layers]
* @param {string} [args.useMethod='POST']
*/
async openMap(args) {
const {
mapid,
version = 'v1',
mapopenway = MapOpenWay.GeomRender,
fileid,
style,
secretKey,
accessKey,
mapbounds,
mapLayerIndex,
layers,
useMethod = 'POST',
...extra
} = args || {};
if (!mapid) throw new Error('openMap: mapid 必填');
const body = { mapid, version, geom: mapopenway !== MapOpenWay.Memory };
if (fileid) body.fileid = fileid;
if (style !== undefined) body.style = style;
if (secretKey) body.secretKey = secretKey;
if (accessKey) body.accessKey = accessKey;
if (mapbounds !== undefined) body.mapbounds = mapbounds;
if (mapLayerIndex !== undefined) body.mapLayerIndex = mapLayerIndex;
if (layers !== undefined) body.layers = layers;
for (const [k, v] of Object.entries(extra)) if (v !== undefined) body[k] = v;
const headers = {};
if (secretKey) headers.secretKey = secretKey;
if (accessKey) headers.accessKey = accessKey;
const url = this.mapUrl(`openmap/${encodeURIComponent(mapid)}`, { version });
const res = await this._request(useMethod, url, { jsonBody: body, headers });
if (res && typeof res === 'object' && res.mapid) {
this.setCurrentMap({
mapid: res.mapid,
version: res.version || version,
layer: res.layer || '',
fileid: res.fileid || fileid || mapid,
});
}
return res;
}
/**
* 更新地图:body 里给**新** fileid 时为已有 mapid 新建版本 vN+1(可带 deleteOldVersion:true
* 删旧版本);fileid 为空或与当前版本相同则按原样重开。与 openMap 同样以 `geom` 决定打开方式
* (body 未给 `geom` 时按 `mapopenway`,缺省 GeomRender → geom:true)。
*/
async updateMap(mapid, version = 'v1', body = {}) {
const { mapopenway, ...rest } = body || {};
const jsonBody = { geom: mapopenway !== MapOpenWay.Memory, ...rest };
const url = this.mapUrl(`updatemap/${encodeURIComponent(mapid)}`, { version });
return await this._request('POST', url, { jsonBody });
}
/**
* 轮询 `cmd/listmaps/{mapid}/{version}` 直到该版本 status 为 finish(或 error/runerror)。
* 对应官方 JS SDK 的 waitMapOpenFinish;GeomRender 首次打开返回 status:"working" 后使用。
* @returns {Promise<{status:'finish'|'error', version?:string, error?:string}>}
*/
async waitMapOpenFinish(mapid, version, { interval = 2000, maxTries = 150 } = {}) {
for (let i = 0; i < maxTries; i++) {
const res = await this._request('GET', this.cmdUrl('listmaps', mapid, version || '_'));
const vers = (res && res[mapid]) || [];
const item = vers.find((v) => !version || version === '_' || v.version === version) || vers[0];
if (item) {
if (item.status === 'finish') return { status: 'finish', version: item.version };
if (item.status === 'error' || item.status === 'runerror') {
return { status: 'error', version: item.version, error: item.errmsg || item.status };
}
}
await new Promise((r) => setTimeout(r, interval));
}
return { status: 'error', error: 'try max times' };
}
async closeMap(mapid, version = 'v1') {
// 对无 body POST 会回 500 param error,SDK 统一走 GET
return await this._request('GET', this.cmdUrl('closemap', mapid, version));
}
// 注意:后端实际读的是 deleteDwgFile(true=同时删除源文件);
// 默认(false)时是否删源文件取决于服务端配置 noAutoDeleteDwgFile。
// 旧版 SDK 的 keepSource 参数后端从未消费过,已移除。
async deleteMap(mapid, version = 'v1', { deleteDwgFile = false } = {}) {
const params = {};
if (deleteDwgFile) params.deleteDwgFile = true;
return await this._request(
'GET',
this.cmdUrl('deletemap', mapid, version, params),
);
}
async renameMap(oldMapid, newMapid) {
return await this._request(
'GET',
this.cmdUrl('renamemap', oldMapid, '_', { newMapId: newMapid }), // 后端读 newMapId(驼峰)
);
}
async deleteMapFile(filenames) {
const files = Array.isArray(filenames) ? filenames.join(';') : String(filenames);
return await this._request(
'GET',
this.cmdUrl('deletemapfile', '_', 'v1', { files }),
);
}
// 后端实际参数:curPage / pageCount / mapIds(数组,无关键字搜索)。
// mapIds 由 toStringList() 读取,必须放 JSON body,故有参时走 POST。
// 旧版 SDK 的 keyword/page/pageSize 后端从未消费过,已移除。
async listMaps({ curPage, pageCount, mapIds } = {}) {
const body = {};
if (curPage !== undefined) body.curPage = curPage;
if (pageCount !== undefined) body.pageCount = pageCount;
if (mapIds !== undefined) {
body.mapIds = Array.isArray(mapIds) ? mapIds : [String(mapIds)];
}
if (Object.keys(body).length === 0) {
return await this._request('GET', this.cmdUrl('listmaps', '_', 'v1'));
}
return await this._request(
'POST',
this.cmdUrl('listmaps', '_', 'v1'),
{ jsonBody: body },
);
}
// 注:cmdMetadata 只解析路径 mapid/version,不读任何业务参数;
// 旧版的 enableEditMeta / fileid 均无消费点,已移除。
async metadata(mapid, version) {
return await this._request(
'GET',
this.cmdUrl('metadata', mapid || '', version || ''),
);
}
async updateMetadata(mapid, version = 'v1', body = {}) {
return await this._request(
'POST',
this.cmdUrl('updateMetadata', mapid, version),
{ jsonBody: body || {} },
);
}
// 后端实际只消费 width/height/darkTheme(layers/backcolor 无消费点,已移除)。
thumbnailUrl(mapid, version, { width = 200, height = 200, darkTheme } = {}) {
const params = { width, height };
if (darkTheme !== undefined) params.darkTheme = darkTheme;
return this.cmdUrl('thumbnail', mapid || '', version || '', params);
}
async thumbnailBytes(mapid, version, opts = {}) {
const url = this.thumbnailUrl(mapid, version, opts);
const resp = await this._request('GET', url, { stream: true });
if (!resp.ok) {
throw new VjmapError(`HTTP ${resp.status} for ${resp.url}`, {
status: resp.status, url: resp.url, body: null,
});
}
return Buffer.from(await resp.arrayBuffer());
}
async supportFormat() {
return await this._request('GET', this.cmdUrl('supportFormat', '_', 'v1'));
}
async constData() {
return await this._request('GET', this.cmdUrl('constData', '_', 'v1'));
}
async getDataBounds(mapid, version) {
return await this._request(
'GET',
this.cmdUrl('getDataBounds', mapid || '', version || ''),
);
}
// ==========================================================================
// 分类 2:瓦片 / 样式(Tiles & Styles)
// ==========================================================================
rasterTileUrl({
mapid, version, layer, fileid,
z = '{z}', x = '{x}', y = '{y}',
} = {}) {
mapid = mapid || this._curMap.mapid || '_';
version = version || this._curMap.version || 'v1';
layer = layer || this._curMap.layer || '_';
fileid = fileid || this._curMap.fileid || '';
const base = `${this.baseUrl}${this._mapPrefix()}tile/${mapid}/${version}/${layer}/${z}/${x}/${y}`;
const qs = encodeQuery({ tag: fileid, token: this.token });
return `${base}?${qs}`;
}
vectorTileUrl({
mapid, version, layer, fileid,
z = '{z}', x = '{x}', y = '{y}',
} = {}) {
mapid = mapid || this._curMap.mapid || '_';
version = version || this._curMap.version || 'v1';
layer = layer || this._curMap.layer || 'main';
fileid = fileid || this._curMap.fileid || '';
const base = `${this.baseUrl}${this._mapPrefix()}tile/${mapid}/${version}/${layer}/${z}/${x}/${y}.mvt`;
const qs = encodeQuery({ tag: fileid, token: this.token });
return `${base}?${qs}`;
}
// 后端实际消费:layers(样式名,复数)、backgroundColor("r,g,b[,a]" 串,
// 仅 transparent=false 时生效,且仅单图 cmd/wms 有效——Go 多图代理
// 不转发它)、transparent、srs/crs、bbox/BBOX、
// width/height(或 WIDTH/HEIGHT)、useImageRotate、fourParameter。
// 旧版参数名 layer/backcolor 后端没有消费点;mvt 仅单图路径支持
// ({version}.mvt 后缀),/service/wms 无此变体。
wmsTileUrl({
mapid, version = 'v1',
layer, width = 256, height = 256,
bbox = '{bbox-epsg-3857}', srs = 'EPSG:3857',
backgroundColor, transparent, mvt = false,
crs, fourParameter,
} = {}) {
let base;
const qsParams = { srs, width, height, token: this.token };
if (Array.isArray(mapid)) {
// 多图:Go 端按 "_," 切分 mapid/version/layers/crs/fourParameter,
// 且五者长度必须一致,否则返回 "paramter error"。
if (mvt) {
throw new VjmapError('/service/wms 不支持 mvt(仅单图 cmd/wms 支持 .mvt 后缀)', {});
}
const rep = (v) => (Array.isArray(v) ? v : new Array(mapid.length).fill(v ?? ''));
for (const [k, v] of [['version', version], ['layer', layer], ['crs', crs], ['fourParameter', fourParameter]]) {
if (Array.isArray(v) && v.length !== mapid.length) {
throw new VjmapError(`wmsTileUrl: ${k} 数组长度须与 mapid 一致`, {});
}
}
base = `${this.baseUrl}service/wms`;
qsParams.mapid = rep(mapid).join('_,');
qsParams.version = rep(version).join('_,');
qsParams.layers = rep(layer).join('_,');
qsParams.crs = rep(crs).join('_,');
qsParams.fourParameter = rep(fourParameter).join('_,');
} else {
const ext = mvt ? '.mvt' : '';
const m = encodeURIComponent(String(mapid));
const v = encodeURIComponent(String(version));
base = `${this.baseUrl}${this._mapPrefix()}cmd/wms/${m}/${v}${ext}`;
if (layer !== undefined) qsParams.layers = layer;
if (crs !== undefined) qsParams.crs = crs;
if (fourParameter !== undefined) qsParams.fourParameter = fourParameter;
}
if (backgroundColor !== undefined) qsParams.backgroundColor = backgroundColor;
if (transparent !== undefined) qsParams.transparent = transparent;
const qs = encodeQuery(qsParams);
return `${base}?bbox=${bbox}&${qs}`;
}
async createMapStyle({
mapid, version,
layeron, layeroff, backcolor, clipbounds,
expression, lineweight, isGeomLayer = true, name,
} = {}) {
const body = { geom: !!isGeomLayer };
if (name !== undefined) body.stylename = name; // 后端读 stylename,不读 name
if (layeron !== undefined) {
body.layeron = Array.isArray(layeron) ? `(${layeron.join(',')})` : layeron;
}
if (layeroff !== undefined) {
body.layeroff = Array.isArray(layeroff) ? `(${layeroff.join(',')})` : layeroff;
}
if (backcolor !== undefined) body.backcolor = backcolor;
if (clipbounds !== undefined) body.clipbounds = clipbounds;
if (expression !== undefined) body.expression = expression;
if (lineweight !== undefined) body.lineweight = JSON.stringify(lineweight);
return await this._request(
'POST',
this.cmdUrl('createMapStyle', mapid || '', version || ''),
{ jsonBody: body },
);
}
async cmdSwitchLayers(visibleLayers, {
expression, darkMode, mapid, version,
} = {}) {
const body = { visibleLayer: visibleLayers };
if (expression !== undefined) body.expression = expression;
if (darkMode !== undefined) body.darkMode = !!darkMode;
return await this._request(
'POST',
this.cmdUrl('switchlayer', mapid || '', version || ''),
{ jsonBody: body },
);
}
async cmdUpdateStyle({
name = '', layeron, layeroff, backcolor,
clipbounds, expression, lineweight,
mapid, version,
} = {}) {
const toLayerStr = (v) => {
if (v === undefined || v === null) return '';
if (Array.isArray(v)) return '(' + v.join(',') + ')';
return String(v);
};
const body = {
stylename: name,
layeron: toLayerStr(layeron),
layeroff: toLayerStr(layeroff),
bkcolor: backcolor !== undefined ? backcolor : '',
expression: expression || '',
lineweight: lineweight ? JSON.stringify(lineweight) : '',
clipbounds: clipbounds === undefined
? ''
: (typeof clipbounds === 'string' ? clipbounds : JSON.stringify(clipbounds)),
};
return await this._request(
'POST',
this.cmdUrl('updateStyle', mapid || '', version || ''),
{ jsonBody: body },
);
}
async sliceLayer({
layer, zoom = 0, ismvt = false,
iscancel = false, isAllCancel = false,
batchNum, idleBatchSleepMs, busyBatchSleepMs,
mapid, version,
} = {}) {
const body = {
layer: layer || this._curMap.layer || '',
zoom,
ismvt: !!ismvt,
iscancel: !!iscancel,
isAllCancel: !!isAllCancel,
};
if (batchNum !== undefined) body.batchnum = batchNum;
if (idleBatchSleepMs !== undefined) body.idle_batch_sleepms = idleBatchSleepMs;
if (busyBatchSleepMs !== undefined) body.busy_batch_sleepms = busyBatchSleepMs;
return await this._request(
'POST',
this.cmdUrl('slicelayer', mapid || '', version || ''),
{ jsonBody: body },
);
}
async getSliceCacheZoom({ layer, ismvt = false, mapid, version } = {}) {
const body = {
layer: layer || this._curMap.layer || '',
ismvt: !!ismvt,
};
return await this._request(
'POST',
this.cmdUrl('getSliceCacheZoom', mapid || '', version || ''),
{ jsonBody: body },
);
}
async deleteStyle(stylename, { mapid, version } = {}) {
return await this._request(
'POST',
this.cmdUrl('deletestyle', mapid || '', version || ''),
{ jsonBody: { styleid: stylename } }, // 后端读 styleid(可给 layername 或 style name,* / a* / m* / s* 为通配)
);
}
// ==========================================================================
// 分类 3:要素查询 / 几何(Query)
// ==========================================================================
async queryFeatures({
querytype,
mapid, version, layer,
x, y, pixelsize,
x1, y1, x2, y2,
expr, condition, beginpos, bounds,
zoom = 1, limit, fields, geom,
simplifyTolerance, useCache, toMapCoordinate,
maxGeomBytesSize, pixelToGeoLength,
extra,
} = {}) {
if (!querytype) throw new VjmapError('queryFeatures: querytype is required');
const body = {
querytype,
zoom,
layername: layer || this._curMap.layer || '',
};
const put = (k, v) => { if (v !== undefined) body[k] = v; };
put('x', x); put('y', y); put('pixelsize', pixelsize);
put('x1', x1); put('y1', y1); put('x2', x2); put('y2', y2);
put('expr', expr); put('condition', condition);
put('beginpos', beginpos);
put('maxReturnCount', limit); put('limit', limit);
put('fields', fields);
put('geom', geom); put('simplifyTolerance', simplifyTolerance);
put('useCache', useCache); put('toMapCoordinate', toMapCoordinate);
put('maxGeomBytesSize', maxGeomBytesSize);
put('pixelToGeoLength', pixelToGeoLength);
// 后端只把 cmdparam.bounds 当**字符串**读(数组会变成空串 → 空间过滤被静默丢弃),且不读 linePts:
// 点数组 [[x,y],...] → "x,y;x,y"(含 ';' 即折线/多边形查询,首尾相同为闭合);
// 4 数 [x1,y1,x2,y2] → "[x1,y1,x2,y2]"(后端剥掉方括号);字符串原样透传。
if (bounds !== undefined && bounds !== null) {
if (Array.isArray(bounds) && Array.isArray(bounds[0])) {
body.bounds = bounds.map((p) => `${p[0]},${p[1]}`).join(';');
} else if (Array.isArray(bounds)) {
body.bounds = JSON.stringify(bounds);
} else {
body.bounds = String(bounds);
}
}
if (extra) Object.assign(body, extra);
return await this._request(
'POST',
this.cmdUrl('queryFeatures', mapid || '', version || ''),
{ jsonBody: body },
);
}
async createEntityGeoData({
mapdata, mapExtent, renderAccuracy,
excludeAttribute = true, useZip = true,
} = {}) {
const body = {
mapdata,
excludeAttribute: !!excludeAttribute,
useZip: !!useZip,
};
if (mapExtent !== undefined) {
body.mapExtent = typeof mapExtent === 'string' ? mapExtent : JSON.stringify(mapExtent);
}
if (renderAccuracy !== undefined) body.renderAccuracy = renderAccuracy;
return await this._request(
'POST',
this.cmdUrl('createEntityGeoData', '_null', 'v1'),
{ jsonBody: body },
);
}
async coordTransform({
srs, crs, points,
fourParameter, isInverseFourParameter,
} = {}) {
const normOne = (p) => {
if (p && typeof p === 'object' && !Array.isArray(p) && 'x' in p && 'y' in p) {
return `${p.x},${p.y}`;
}
return `${p[0]},${p[1]}`;
};
let pts;
if (points && !Array.isArray(points) && typeof points === 'object') {
pts = [normOne(points)];
} else if (Array.isArray(points) && typeof points[0] === 'number') {
pts = [normOne(points)];
} else {
pts = points.map(normOne);
}
const body = { srs, crs, points: pts.join(';') };
if (fourParameter !== undefined) {
body.fourParameter = Array.isArray(fourParameter)
? fourParameter.join(',') : fourParameter;
}
if (isInverseFourParameter === true) body.isInverseFourParamter = true;
return await this._request(
'POST',
this.cmdUrl('coordtransform', '_', '_'),
{ jsonBody: body },
);
}
async prjWktToPrj4Str(wkt, { from = '' } = {}) {
return await this._request(
'POST',
this.cmdUrl('prjWktToPrj4Str', '_', '_'),
{ jsonBody: { wkt, from } },
);
}
// ==========================================================================
// 分类 4:图形分析(Analysis),统一 pass-through 风格
// ==========================================================================
async _postCmdNull(cmd, params = {}) {
return await this._request(
'POST',
this.cmdUrl(cmd, '_null', 'v1'),
{ jsonBody: { ...params } },
);
}
async cmdMapDiff(params = {}) {
return await this._postCmdNull('mapDiff', params);
}
async cmdMapCompare(params = {}) {
return await this._postCmdNull('mapCompare', params);
}
async cmdExtractTable(params = {}) {
return await this._postCmdNull('extractTable', params);
}
async cmdExtractCenterLines(params = {}) {
return await this._postCmdNull('extractCenterLines', params);
}
async cmdMatchObject(params = {}) {
return await this._postCmdNull('objectMatch', params);
}
async cmdImageSvgToDwg(params = {}) {
return await this._postCmdNull('imageSvgToDwg', params);
}
// ==========================================================================
// 分类 5:组合 / 拆分 / 导出
// ==========================================================================
async cmdComposeNewMap(params = {}) {
return await this._postCmdNull('composeNewMap', params);
}
async cmdSplitMap(params = {}) {
return await this._postCmdNull('cmdSplitMap', params);
}
async cmdExportLayout(params = {}) {
return await this._postCmdNull('exportLayout', params);
}
/**
* cmd 路由对**空 JSON body 的 POST** 直接回 500 "param error",而 GET 不校验。
* 参数为空时改走 GET(无 query),否则 POST JSON。
*/
async _cmdPostOrGet(cmd, mapid, version, params = {}) {
const url = this.cmdUrl(cmd, mapid || '', version || '');
if (Object.keys(params).length === 0) return await this._request('GET', url);
return await this._request('POST', url, { jsonBody: { ...params } });
}
async cmdExportPdf(params = {}) {
const { mapid, version, ...rest } = params;
return await this._cmdPostOrGet('exportPdf', mapid, version, rest);
}
async cmdExportDxf(params = {}) {
const { mapid, version, ...rest } = params;
return await this._cmdPostOrGet('exportDxf', mapid, version, rest);
}
async cmdSaveOfflineTileDb(params = {}) {
return await this._postCmdNull('saveOfflineTileDb', params);
}
// ==========================================================================
// 分类 6:WebCAD(cmd 始终注册;/service/webcad 是前端 license 握手,不是启用探针)
// ==========================================================================
async listWebcadDraws() {
return await this._request('GET', this.cmdUrl('listWebcadDraws', '_', 'v1'));
}
async getWebcadData(mapid, version = 'v1', params = {}) {
return await this._request(
'POST',
this.cmdUrl('getWebcadData', mapid, version),
{ jsonBody: { ...params } },
);
}
async saveWebcadPatch(mapid, version = 'v1', params = {}) {
return await this._request(
'POST',
this.cmdUrl('saveWebcadPatch', mapid, version),
{ jsonBody: { ...params } },
);
}
async deleteWebcadDraw(mapid, version = 'v1', params = {}) {
return await this._cmdPostOrGet('deleteWebcadDraw', mapid, version, params);
}
async convertWebcad(mapid, version = 'v1', params = {}) {
return await this._cmdPostOrGet('convertWebCAD', mapid, version, params);
}
async exportWebcad(params = {}) {
return await this._postCmdNull('exportWebCAD', params);
}
// ==========================================================================
// 分类 7:工作区
// ==========================================================================
async getWorkspaces() {
return await this._request('GET', this.serviceUrl('getWorkspace'));
}
async workspaceCreate({
name, alias = '', workDir = '',
isPublic = false, disableFullSearch = false,
} = {}) {
return await this._request(
'POST',
this.serviceUrl('workspaceCreate'),
{ jsonBody: { name, alias, workDir, isPublic: !!isPublic, disableFullSearch: !!disableFullSearch } },
);
}
async workspaceModify(workspace = {}) {
return await this._request(
'POST',
this.serviceUrl('workspaceModify'),
{ jsonBody: { ...workspace } },
);
}
async workspaceDelete(name) {
const url = this.serviceUrl(`workspaceDelete/${encodeURIComponent(name)}`);
return await this._request('DELETE', url);
}
async workspaceStatus() {
return await this._request('GET', this.serviceUrl('workspaceStatus'));
}
// ==========================================================================
// 分类 8:KV 键值存储
// ==========================================================================
async getCustomData(key, { retDataType, contentType } = {}) {
const keySeg = Array.isArray(key)
? key.map(k => encodeURIComponent(String(k))).join(';;')
: encodeURIComponent(String(key));
const params = {};
if (retDataType !== undefined) params.retDataType = retDataType;
if (contentType !== undefined) params.contentType = contentType;
return await this._request('GET', this.serviceUrl(`data/${keySeg}`, params));
}
/**
* 存值:POST JSON `{value, prop}`(两者都必须是字符串,后端 ShouldBindJSON 绑定 string)。
* 二进制请用 `binary`(Buffer/Uint8Array):SDK 转 base64 存进 value,prop 缺省记为
* `base64;<mime>`(mime 缺省 application/octet-stream);回读 `Buffer.from(d.data, 'base64')`。
* 后端 raw body 分支不可用(ShouldBindJSON 已先读走 body,小 body 存成 ""、大 body 被截断),SDK 不提供。
*/
async saveCustomData(key, { value, prop, ttl, binary, mime } = {}) {
const params = {};
if (ttl !== undefined && ttl > 0) params.ttl = String(ttl);
const url = this.serviceUrl(`data/${encodeURIComponent(key)}`, params);
const body = {};
if (binary !== undefined && binary !== null) {
body.value = Buffer.from(binary).toString('base64');
body.prop = prop !== undefined ? prop : `base64;${mime || 'application/octet-stream'}`;
} else {
if (value !== undefined) body.value = value;
if (prop !== undefined) body.prop = prop;
}
return await this._request('POST', url, { jsonBody: body });
}
async deleteCustomData(key, { isPrefix = false } = {}) {
const keySeg = Array.isArray(key)
? key.map(k => encodeURIComponent(String(k))).join(';;')
: encodeURIComponent(String(key));
const params = isPrefix ? { isPrefix: 'true' } : {};
return await this._request('DELETE', this.serviceUrl(`data/${keySeg}`, params));
}
async saveMultipleCustomData(entries = []) {
return await this._request(
'POST',
this.serviceUrl('datas'),
{ jsonBody: entries.map(e => ({ ...e })) },
);
}
async getCustomDataKeys(prefix = '', { retDataType, isSys = false } = {}) {
const params = { prefix };
if (retDataType !== undefined) params.retDataType = retDataType;
if (isSys) params.isSys = 'true';
return await this._request('GET', this.serviceUrl('datakeys', params));
}
// ==========================================================================
// 分类 9:全文搜索
// ==========================================================================
async fullSearch(query = '', {
mapVer, type, workspace, limit = 10, offset = 0,
fields, facet, boundingBox, timeRange, useGet = false,
} = {}) {
const body = { query, limit, offset };
if (mapVer !== undefined) body.map_ver = mapVer;
if (type !== undefined) body.type = type;
if (workspace !== undefined) body.workspace = workspace;
if (fields !== undefined) body.fields = fields;
if (facet !== undefined) body.facet = !!facet;
if (boundingBox !== undefined) body.bounding_box = boundingBox;
if (timeRange !== undefined) body.time_range = timeRange;
if (useGet) {
return await this._request('GET', this.serviceUrl('fullsearch/search', body));
}
return await this._request(
'POST',
this.serviceUrl('fullsearch/search'),
{ jsonBody: body },
);
}
async fullSearchAdd(document) {
return await this._request(
'POST',
this.serviceUrl('fullsearch/add'),
{ jsonBody: { document: { ...document } } },
);
}
async fullSearchDelete(id) {
return await this._request(
'DELETE',
this.serviceUrl('fullsearch/delete'),
{ jsonBody: { id } },
);
}
// ==========================================================================
// 分类 10:符号库
// ==========================================================================
async symbolCategories() {
return await this._request('GET', this.serviceUrl('symbol/categories'));
}
async symbolList(categoryId, { page = 1, pageSize = 20, keyword } = {}) {
const params = { page, pageSize };
if (keyword !== undefined && keyword !== '') params.keyword = keyword;
return await this._request(
'GET',
this.serviceUrl(`symbol/list/${encodeURIComponent(categoryId)}`, params),
);
}
async symbolGet(id) {
return await this._request(
'GET',
this.serviceUrl(`symbol/${encodeURIComponent(id)}`),
);
}
// ==========================================================================
// 分类 12:/service 杂项
// ==========================================================================
qrcodeUrl(content, size = 256) {
return this.serviceUrl('qrcode', { content, size });
}
async qrcodeBytes(content, size = 256) {
const resp = await this._request('GET', this.qrcodeUrl(content, size), { stream: true });
if (!resp.ok) {
throw new VjmapError(`HTTP ${resp.status} for ${resp.url}`,
{ status: resp.status, url: resp.url, body: null });
}
return Buffer.from(await resp.arrayBuffer());
}
glyphsUrl(fontstack, range = '0-255') {
const fs = encodeURIComponent(fontstack).replace(/%2C/g, ',');
let r = range;
if (!r.endsWith('.pbf')) r = `${r}.pbf`;
return this.serviceUrl(`fonts/${fs}/${r}`);
}
async glyphsBytes(fontstack, range = '0-255') {
const resp = await this._request('GET', this.glyphsUrl(fontstack, range), { stream: true });
if (!resp.ok) {
throw new VjmapError(`HTTP ${resp.status} for ${resp.url}`,
{ status: resp.status, url: resp.url, body: null });
}
return Buffer.from(await resp.arrayBuffer());
}
// ==========================================================================
// 分类 13:AI(条件启用)
// ==========================================================================
async aiEnabled() {
try {
await this.chatFaq();
return true;
} catch (_) {
return false;
}
}
async vectorSearch(query = '', {
numDocuments = 20, collection = 'vj',
where, whereDocument, searchDescription,
disableSplitSentence = false, disableSearchKnowledge = false,
documents, useGet = false,
} = {}) {
const body = {
query, numDocuments, collection,
disableSplitSentence: !!disableSplitSentence,
disableSearchKnowledge: !!disableSearchKnowledge,
};
if (where !== undefined) body.where = where;
if (whereDocument !== undefined) body.whereDocument = whereDocument;
if (searchDescription !== undefined) body.searchDescription = searchDescription;
if (documents !== undefined) body.documents = documents.map(d => ({ ...d }));
if (useGet) {
const params = { query, numDocuments, collection };
if (where !== undefined) params.where = where;
if (whereDocument !== undefined) params.whereDocument = whereDocument;
return await this._request('GET', this.serviceUrl('vectorSearch', params));
}
return await this._request('POST', this.serviceUrl('vectorSearch'), { jsonBody: body });
}
async chatFaq() {
return await this._request('GET', this.serviceUrl('chatFaq'));
}
async vectorDocs() {
return await this._request('GET', this.serviceUrl('vectorDocs'));
}
async uploadImg(file, { uri, filename, watermark = false, authorization } = {}) {
if (uri === undefined) throw new VjmapError('uploadImg: uri is required');
const form = new FormData();
let blob; let fname;
if (typeof file === 'string') {
const fs = await import('node:fs/promises');
const pathMod = await import('node:path');
const buf = await fs.readFile(file);
fname = filename || pathMod.basename(file);
blob = new Blob([buf]);
} else if (file && typeof file === 'object' && 'buffer' in file && 'name' in file) {
fname = filename || file.name;
blob = new Blob([file.buffer]);
} else if (file instanceof Uint8Array || Buffer.isBuffer(file)) {
fname = filename || 'upload.bin';
blob = new Blob([file]);
} else {
throw new VjmapError('uploadImg: unsupported file type');
}
form.append('upload', blob, fname);
const headers = {
'Authorization': authorization || 'Basic X3ZqbWFwQF8=',
'Uri': uri,
};
if (watermark) headers['watermark'] = '1';
return await this._request(
'POST',
this.serviceUrl('uploadImg'),
{ form, headers },
);
}
}
// ---------------------------------------------------------------------------
module.exports = {
Vjmap,
VjmapError,
MapOpenWay,
computeSecretKey,
fileMd5,
md5Hex,
};