- 新增「设置」Tab,支持物品分类的增删改(数据库持久化) - 物品分类由硬编码枚举迁移为动态分类,通过 CategoriesContext 全局共享 - items 表新增 unit(单位)、spec(规格)字段,附带旧库迁移 - markers 表 CHECK 约束扩展支持第 4 楼(楼顶),附带重建表迁移 - SVG 地图渲染:overdraw 预渲染提升清晰度,嵌入图片改用原生 Image 渲染 - FoldableLayout 改为 showDetail prop 控制动画宽度 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
193 lines
6.7 KiB
JavaScript
193 lines
6.7 KiB
JavaScript
// 将 img/ 目录下的 SVG 底图转换为 TS 模块
|
||
// 使用方式:pnpm generate-maps
|
||
const fs = require('fs')
|
||
const path = require('path')
|
||
|
||
const imgDir = path.resolve(__dirname, '../apps/property-survey/img')
|
||
const outputFile = path.resolve(
|
||
__dirname,
|
||
'../apps/property-survey/src/features/map/floorMaps.generated.ts',
|
||
)
|
||
|
||
// 按楼层查找候选文件名(优先带 -1 后缀的新版本)
|
||
const FLOOR_CANDIDATES = {
|
||
1: ['1101-1.svg', '1101.svg'],
|
||
2: ['1102-1.svg', '1102.svg'],
|
||
3: ['1103-1.svg', '1103.svg'],
|
||
4: ['1104-1.svg', '1104.svg'],
|
||
}
|
||
|
||
function findFile(candidates) {
|
||
for (const name of candidates) {
|
||
const full = path.join(imgDir, name)
|
||
if (fs.existsSync(full)) return full
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 解析 SVG transform 字符串,返回 matrix(a,b,c,d,e,f) 的 6 个分量
|
||
* 支持 rotate(angle) / rotate(angle,cx,cy) / matrix(a,b,c,d,e,f)
|
||
*/
|
||
function parseTransform(transformStr) {
|
||
if (!transformStr) return [1, 0, 0, 1, 0, 0]
|
||
|
||
const rotateM = transformStr.trim().match(/^rotate\(\s*([^,)]+)(?:\s*,\s*([^,)]+)\s*,\s*([^)]+))?\s*\)$/)
|
||
if (rotateM) {
|
||
const angle = parseFloat(rotateM[1]) * Math.PI / 180
|
||
const rcx = rotateM[2] ? parseFloat(rotateM[2]) : 0
|
||
const rcy = rotateM[3] ? parseFloat(rotateM[3]) : 0
|
||
const cosA = Math.cos(angle), sinA = Math.sin(angle)
|
||
return [
|
||
cosA, sinA, -sinA, cosA,
|
||
rcx * (1 - cosA) + rcy * sinA,
|
||
rcy * (1 - cosA) - rcx * sinA,
|
||
]
|
||
}
|
||
|
||
const matrixM = transformStr.trim().match(/^matrix\(\s*([^)]+)\s*\)$/)
|
||
if (matrixM) {
|
||
return matrixM[1].split(/[\s,]+/).map(parseFloat)
|
||
}
|
||
|
||
return [1, 0, 0, 1, 0, 0]
|
||
}
|
||
|
||
/**
|
||
* 从 SVG 提取所有用户嵌入的 <image> 元素(非背景图)
|
||
* 返回结构化数据,供 FloorMap.tsx 用原生 Image 渲染
|
||
*/
|
||
function extractEmbeddedImages(svgText) {
|
||
const results = []
|
||
const regex = /<image([^>]*)\/>/gs
|
||
let m
|
||
while ((m = regex.exec(svgText)) !== null) {
|
||
const attrs = m[0]
|
||
const uriM = attrs.match(/(?:xlink:href|href)="(data:image\/[^"]+)"/)
|
||
if (!uriM) continue
|
||
|
||
const uri = uriM[1].replace(/ | |\s/g, '')
|
||
const W = parseFloat(attrs.match(/width="([^"]*)"/)?.[1] || '0')
|
||
const H = parseFloat(attrs.match(/height="([^"]*)"/)?.[1] || '0')
|
||
const ox = parseFloat(attrs.match(/\sx="([^"]*)"/)?.[1] || '0')
|
||
const oy = parseFloat(attrs.match(/\sy="([^"]*)"/)?.[1] || '0')
|
||
const opacityStyle = attrs.match(/opacity:([^;}"]*)/)?.[1]
|
||
const opacityAttr = attrs.match(/\sopacity="([^"]*)"/)?.[1]
|
||
const opacity = parseFloat(opacityStyle ?? opacityAttr ?? '1')
|
||
|
||
const transformStr = attrs.match(/transform="([^"]*)"/)?.[1]
|
||
const [a, b, c, d, e, f] = parseTransform(transformStr)
|
||
|
||
// 图片在 SVG 坐标系下的中心点
|
||
const cx = a * (ox + W / 2) + c * (oy + H / 2) + e
|
||
const cy = b * (ox + W / 2) + d * (oy + H / 2) + f
|
||
|
||
// 旋转角度(度)
|
||
const angleDeg = Math.atan2(b, a) * (180 / Math.PI)
|
||
|
||
results.push({ uri, width: W, height: H, cx, cy, angleDeg, opacity })
|
||
}
|
||
return results
|
||
}
|
||
|
||
/**
|
||
* 移除 SVG 文本中的第一个 <image> 元素(背景图)
|
||
*/
|
||
function removeFirstImage(svg) {
|
||
const m = svg.match(/(?:xlink:href|href)="(data:image\/[^"]+)"/)
|
||
if (!m) return svg
|
||
const tagStart = svg.lastIndexOf('<image', m.index)
|
||
const gtPos = svg.indexOf('>', tagStart)
|
||
const tagEnd = svg[gtPos - 1] === '/' ? gtPos + 1 : (() => {
|
||
const close = svg.indexOf('</image>', gtPos)
|
||
return close !== -1 ? close + 8 : gtPos + 1
|
||
})()
|
||
return svg.slice(0, tagStart) + svg.slice(tagEnd)
|
||
}
|
||
|
||
/**
|
||
* 移除 SVG 文本中的所有 <image> 元素(已提取到 embeddedImages 后清理)
|
||
*/
|
||
function removeAllImages(svg) {
|
||
return svg.replace(/<image[^>]*\/>/gs, '')
|
||
}
|
||
|
||
const contents = {}
|
||
for (const [floor, candidates] of Object.entries(FLOOR_CANDIDATES)) {
|
||
const filePath = findFile(candidates)
|
||
if (!filePath) {
|
||
console.error(`❌ 找不到 ${floor}F 的 SVG 文件(查找:${candidates.join(', ')})`)
|
||
process.exit(1)
|
||
}
|
||
console.log(`读取 ${floor}F → ${path.basename(filePath)}`)
|
||
const svg = fs.readFileSync(filePath, 'utf8')
|
||
|
||
// 1. 提取背景图 URI(第一个 <image>)
|
||
const bgMatch = svg.match(/(?:xlink:href|href)="(data:image\/[^"]+)"/)
|
||
const imageUri = bgMatch ? bgMatch[1].replace(/ | |\s/g, '') : null
|
||
if (imageUri) console.log(` → 提取背景图 URI`)
|
||
|
||
// 2. 移除背景图后,提取剩余嵌入图片
|
||
const svgNoBg = removeFirstImage(svg)
|
||
const embeddedImages = extractEmbeddedImages(svgNoBg)
|
||
console.log(` → 嵌入图片(用户添加): ${embeddedImages.length} 张`)
|
||
|
||
// 3. 清理 SVG:移除所有 <image>(已单独提取)、Inkscape 专属元素和属性
|
||
let svgXml = removeAllImages(svgNoBg)
|
||
svgXml = svgXml.replace(/<sodipodi:namedview[\s\S]*?\/>/g, '')
|
||
svgXml = svgXml.replace(/<sodipodi:namedview[\s\S]*?<\/sodipodi:namedview>/g, '')
|
||
svgXml = svgXml.replace(/\s+(?:inkscape|sodipodi):[a-z:A-Z-]+=(?:"[^"]*"|'[^']*')/g, '')
|
||
svgXml = svgXml.replace(/\s+xmlns:(?:inkscape|sodipodi)="[^"]*"/g, '')
|
||
svgXml = svgXml.replace(/\s+xmlns:svg="[^"]*"/g, '')
|
||
|
||
// 4. 确保 viewBox,将 width/height 改为 100%
|
||
const hasViewBox = /viewBox=/.test(svgXml)
|
||
const wMatch = svgXml.match(/<svg[^>]*\swidth="([^"%][^"]*)"/)
|
||
const hMatch = svgXml.match(/<svg[^>]*\sheight="([^"%][^"]*)"/)
|
||
if (!hasViewBox && wMatch && hMatch) {
|
||
svgXml = svgXml.replace(/<svg/, `<svg viewBox="0 0 ${wMatch[1]} ${hMatch[1]}"`)
|
||
}
|
||
svgXml = svgXml.replace(/(<svg[^>]*)\swidth="[^%][^"]*"/, '$1 width="100%"')
|
||
svgXml = svgXml.replace(/(<svg[^>]*)\sheight="[^%][^"]*"/, '$1 height="100%"')
|
||
svgXml = svgXml.replace(/xlink:href/g, 'href')
|
||
svgXml = svgXml.replace(/<\?xml[^?]*\?>/g, '').trim()
|
||
svgXml = svgXml.replace(/<!--[\s\S]*?-->/g, '')
|
||
svgXml = svgXml.replace(/\n\s*\n/g, '\n').trim()
|
||
|
||
contents[floor] = { imageUri, svgXml, embeddedImages }
|
||
}
|
||
|
||
const output = `// 自动生成,请勿手动编辑
|
||
// 更新底图后运行:pnpm generate-maps
|
||
import type { FloorNumber } from '@repo/types'
|
||
|
||
export type EmbeddedImage = {
|
||
uri: string
|
||
width: number // SVG 单位
|
||
height: number // SVG 单位
|
||
cx: number // 图片中心 x(SVG 坐标系,0-3200)
|
||
cy: number // 图片中心 y(SVG 坐标系,0-2133)
|
||
angleDeg: number // 旋转角度(度)
|
||
opacity: number
|
||
}
|
||
|
||
export type FloorMap = {
|
||
imageUri: string | null
|
||
svgXml: string
|
||
embeddedImages: EmbeddedImage[]
|
||
}
|
||
|
||
const f1: FloorMap = ${JSON.stringify(contents[1])}
|
||
|
||
const f2: FloorMap = ${JSON.stringify(contents[2])}
|
||
|
||
const f3: FloorMap = ${JSON.stringify(contents[3])}
|
||
|
||
const f4: FloorMap = ${JSON.stringify(contents[4])}
|
||
|
||
export const FLOOR_SVG: Record<FloorNumber, FloorMap> = { 1: f1, 2: f2, 3: f3, 4: f4 }
|
||
`
|
||
|
||
fs.writeFileSync(outputFile, output, 'utf8')
|
||
console.log(`✅ 已生成 ${outputFile}`)
|