react-phone-apps/scripts/generate-maps.js
lanrtop eab08d8c96 feat: 新增物品分类管理与第四楼支持
- 新增「设置」Tab,支持物品分类的增删改(数据库持久化)
- 物品分类由硬编码枚举迁移为动态分类,通过 CategoriesContext 全局共享
- items 表新增 unit(单位)、spec(规格)字段,附带旧库迁移
- markers 表 CHECK 约束扩展支持第 4 楼(楼顶),附带重建表迁移
- SVG 地图渲染:overdraw 预渲染提升清晰度,嵌入图片改用原生 Image 渲染
- FoldableLayout 改为 showDetail prop 控制动画宽度

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 23:12:52 +09:00

193 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 将 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(/&#10;|&#13;|\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(/&#10;|&#13;|\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 // 图片中心 xSVG 坐标系0-3200
cy: number // 图片中心 ySVG 坐标系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}`)