/* 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', ''); * 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 缺省 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, };