<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>智能户型图生成器 v2.0</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: #2c3e50;
}
.app {
max-width: 1600px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
color: #fff;
margin-bottom: 20px;
}
header h1 {
font-size: 2em;
margin-bottom: 6px;
text-shadow: 0 2px 10px rgba(0,0,0,0.2);
}
header p { opacity: 0.9; font-size: 0.95em; }
.main-grid {
display: grid;
grid-template-columns: 280px 1fr 280px;
gap: 16px;
}
@media (max-width: 1200px) {
.main-grid { grid-template-columns: 1fr; }
}
.panel {
background: rgba(255,255,255,0.97);
border-radius: 14px;
padding: 18px;
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
backdrop-filter: blur(10px);
}
.panel h3 {
font-size: 1em;
margin-bottom: 12px;
color: #667eea;
border-bottom: 2px solid #f0f0f0;
padding-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.form-group { margin-bottom: 12px; }
.form-group label {
display: block;
font-size: 0.8em;
color: #666;
margin-bottom: 4px;
font-weight: 500;
}
.form-group select,
.form-group input {
width: 100%;
padding: 8px 10px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 0.9em;
background: #fafafa;
transition: all 0.2s;
}
.form-group select:focus,
.form-group input:focus {
outline: none;
border-color: #667eea;
background: #fff;
}
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.85em;
cursor: pointer;
padding: 4px 8px;
background: #f5f5f5;
border-radius: 6px;
transition: all 0.2s;
}
.checkbox-group label:hover { background: #eef; }
.checkbox-group input { width: auto; }
.btn {
width: 100%;
padding: 10px;
border: none;
border-radius: 8px;
font-size: 0.9em;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 8px;
}
.btn-primary {
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
}
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(102,126,234,0.4); }
.btn-secondary {
background: #f0f0f0;
color: #333;
}
.btn-secondary:hover { background: #e0e0e0; }
.btn-group { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.canvas-wrap {
background: #fff;
border-radius: 14px;
padding: 16px;
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
position: relative;
min-height: 600px;
}
canvas {
display: block;
margin: 0 auto;
max-width: 100%;
cursor: crosshair;
background: #fafafa;
border-radius: 8px;
}
.tooltip {
position: absolute;
background: rgba(0,0,0,0.85);
color: #fff;
padding: 8px 12px;
border-radius: 8px;
font-size: 0.85em;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
z-index: 100;
max-width: 200px;
}
.tooltip.show { opacity: 1; }
.stats { font-size: 0.85em; }
.stat-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px dashed #eee;
}
.stat-row:last-child { border-bottom: none; }
.stat-label { color: #666; }
.stat-value { font-weight: 600; color: #2c3e50; }
.pie-chart {
width: 100%;
height: 160px;
margin: 10px 0;
}
.room-list {
max-height: 280px;
overflow-y: auto;
font-size: 0.85em;
}
.room-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
border-radius: 6px;
margin-bottom: 3px;
cursor: pointer;
transition: all 0.15s;
}
.room-item:hover { background: #f5f5f5; }
.room-item.active { background: #eef; }
.room-color {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 3px;
margin-right: 6px;
vertical-align: middle;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
font-size: 0.75em;
}
.legend-item {
display: flex;
align-items: center;
gap: 3px;
padding: 2px 6px;
background: #f5f5f5;
border-radius: 4px;
}
.badge {
display: inline-block;
padding: 2px 8px;
background: #667eea;
color: #fff;
border-radius: 10px;
font-size: 0.75em;
font-weight: 600;
}
.alert {
padding: 10px;
background: #fff3cd;
border-left: 3px solid #ffc107;
border-radius: 4px;
font-size: 0.8em;
margin-bottom: 10px;
}
.seed-display {
font-family: monospace;
background: #f5f5f5;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8em;
color: #667eea;
}
@media print {
body { background: #fff; }
.panel:not(.canvas-wrap) { display: none; }
.main-grid { grid-template-columns: 1fr; }
header { color: #000; }
}
</style>
</head>
<body>
<div class="app">
<header>
<h1>🏗️ 智能户型图生成器 v2.0</h1>
<p>基于真实户型逻辑 · 模板驱动 · 邻接规则验证</p>
</header>
<div class="main-grid">
<!-- 左侧控制面板 -->
<aside class="panel">
<h3>⚙️ 户型配置</h3>
<div class="form-group">
<label>户型类型</label>
<select id="houseType">
<option value="one-bed">一室一厅</option>
<option value="two-bed" selected>两室一厅</option>
<option value="three-bed">三室一厅</option>
<option value="three-bed-two">三室两厅</option>
</select>
</div>
<div class="form-group">
<label>布局模板</label>
<select id="template">
<option value="classic">经典南北通透</option>
<option value="compact">紧凑实用</option>
<option value="luxury">豪华舒适</option>
</select>
</div>
<div class="form-group">
<label>变化方式</label>
<select id="variation">
<option value="auto">自动随机</option>
<option value="normal">标准</option>
<option value="mirror">镜像翻转</option>
<option value="swap">同类互换</option>
</select>
</div>
<div class="form-group">
<label>装修风格</label>
<select id="theme">
<option value="modern">现代简约</option>
<option value="chinese">新中式</option>
<option value="nordic">北欧</option>
<option value="minimal">极简</option>
</select>
</div>
<div class="form-group">
<label>显示选项</label>
<div class="checkbox-group">
<label><input type="checkbox" id="showFurniture" checked> 家具</label>
<label><input type="checkbox" id="showDimensions" checked> 尺寸</label>
<label><input type="checkbox" id="showGrid"> 网格</label>
<label><input type="checkbox" id="showFlow"> 动线</label>
<label><input type="checkbox" id="showDoors" checked> 门窗</label>
<label><input type="checkbox" id="showLabels" checked> 标注</label>
</div>
</div>
<button class="btn btn-primary" onclick="regenerate()">🎲 重新生成</button>
<div class="btn-group">
<button class="btn btn-secondary" onclick="saveLayout()">💾 保存</button>
<button class="btn btn-secondary" onclick="loadLayout()">📂 加载</button>
</div>
<button class="btn btn-secondary" onclick="exportPNG()">🖼️ 导出PNG</button>
<button class="btn btn-secondary" onclick="window.print()">🖨️ 打印</button>
<h3 style="margin-top:16px;">🎯 当前种子</h3>
<div class="seed-display" id="seedDisplay">--</div>
</aside>
<!-- 中间画布 -->
<main class="canvas-wrap">
<canvas id="canvas" width="900" height="680"></canvas>
<div class="tooltip" id="tooltip"></div>
<div class="legend" id="legend"></div>
</main>
<!-- 右侧信息面板 -->
<aside class="panel">
<h3>📊 户型信息</h3>
<div class="stats" id="stats">
<div class="stat-row"><span class="stat-label">总面积</span><span class="stat-value" id="sTotal">--</span></div>
<div class="stat-row"><span class="stat-label">套内面积</span><span class="stat-value" id="sInner">--</span></div>
<div class="stat-row"><span class="stat-label">得房率</span><span class="stat-value" id="sRate">--</span></div>
<div class="stat-row"><span class="stat-label">卧室数</span><span class="stat-value" id="sBed">--</span></div>
<div class="stat-row"><span class="stat-label">卫生间</span><span class="stat-value" id="sBath">--</span></div>
<div class="stat-row"><span class="stat-label">布局类型</span><span class="stat-value" id="sLayout">--</span></div>
<div class="stat-row"><span class="stat-label">朝向</span><span class="stat-value" id="sOrient">南北通透</span></div>
</div>
<h3 style="margin-top:16px;">🥧 面积分布</h3>
<canvas class="pie-chart" id="pieChart" width="240" height="160"></canvas>
<h3 style="margin-top:16px;">🏠 房间列表</h3>
<div class="room-list" id="roomList"></div>
</aside>
</div>
</div>
<script>
// ========== 主题配色 ==========
const THEMES = {
modern: {
'主卧': '#E8B4BC', '次卧': '#B8D4E3', '次卧1': '#B8D4E3', '次卧2': '#C5D5E4',
'客厅': '#F4E4BC', '餐厅': '#E8D5B7', '厨房': '#D4B896',
'卫生间': '#A8D5E2', '卫': '#A8D5E2', '主卫': '#89C4D9',
'阳台': '#C8E6C9', '走廊': '#E0E0E0', '玄关': '#FFE0B2',
'衣帽': '#E1BEE7', '储物': '#D7CCC8'
},
chinese: {
'主卧': '#D4A574', '次卧': '#B8956A', '次卧1': '#B8956A', '次卧2': '#C4A07A',
'客厅': '#C9302C', '餐厅': '#A52A2A', '厨房': '#8B4513',
'卫生间': '#4682B4', '卫': '#4682B4', '主卫': '#5F9EA0',
'阳台': '#6B8E23', '走廊': '#D2B48C', '玄关': '#CD853F',
'衣帽': '#8B0000', '储物': '#A0522D'
},
nordic: {
'主卧': '#F5E6D3', '次卧': '#E8D5C4', '次卧1': '#E8D5C4', '次卧2': '#EDDCCE',
'客厅': '#FFF8E7', '餐厅': '#F0E4D0', '厨房': '#D4C5A9',
'卫生间': '#B5C7D3', '卫': '#B5C7D3', '主卫': '#9DB4C0',
'阳台': '#C1D5B0', '走廊': '#E8E8E8', '玄关': '#E8D4B8',
'衣帽': '#D4B8C8', '储物': '#C9B8A8'
},
minimal: {
'主卧': '#F0F0F0', '次卧': '#E8E8E8', '次卧1': '#E8E8E8', '次卧2': '#ECECEC',
'客厅': '#FAFAFA', '餐厅': '#F5F5F5', '厨房': '#DCDCDC',
'卫生间': '#D0D8E0', '卫': '#D0D8E0', '主卫': '#C0C8D0',
'阳台': '#E0E8D8', '走廊': '#E5E5E5', '玄关': '#EBE5DC',
'衣帽': '#E0D8E0', '储物': '#DDD8D0'
}
};
// ========== 户型模板 (基于真实户型逻辑设计) ==========
// 每个模板是二维网格,同名字符合并为同一房间
// 设计原则:玄关在入口、厨房邻餐厅、主卫通主卧、阳台靠外墙、走廊连通各室
const TEMPLATES = {
'one-bed': {
classic: [
['主卧','主卧','主卧','卫','卫','客厅'],
['主卧','主卧','主卧','卫','卫','客厅'],
['主卧','主卧','主卧','走廊','走廊','客厅'],
['厨房','厨房','餐厅','餐厅','走廊','客厅'],
['厨房','厨房','餐厅','餐厅','客厅','客厅'],
['玄关','玄关','玄关','玄关','阳台','阳台'],
]
},
'two-bed': {
classic: [
['主卧','主卧','主卧','卫','卫','次卧','次卧','次卧'],
['主卧','主卧','主卧','卫','卫','次卧','次卧','次卧'],
['主卧','主卧','主卧','走廊','走廊','走廊','走廊','走廊'],
['厨房','厨房','餐厅','餐厅','走廊','客厅','客厅','客厅'],
['厨房','厨房','餐厅','餐厅','走廊','客厅','客厅','阳台'],
['玄关','玄关','玄关','餐厅','走廊','客厅','客厅','阳台'],
['玄关','玄关','玄关','玄关','玄关','阳台','阳台','阳台'],
],
compact: [
['主卧','主卧','主卧','卫','次卧','次卧','次卧'],
['主卧','主卧','主卧','卫','次卧','次卧','次卧'],
['走廊','走廊','走廊','走廊','走廊','走廊','走廊'],
['厨房','餐厅','餐厅','客厅','客厅','客厅','阳台'],
['厨房','餐厅','餐厅','客厅','客厅','客厅','阳台'],
['玄关','玄关','玄关','客厅','客厅','阳台','阳台'],
],
luxury: [
['主卧','主卧','主卧','主卫','走廊','次卧','次卧','卫'],
['主卧','主卧','主卧','主卫','走廊','次卧','次卧','卫'],
['主卧','主卧','衣帽','衣帽','走廊','次卧','次卧','卫'],
['厨房','厨房','餐厅','餐厅','走廊','走廊','走廊','走廊'],
['厨房','厨房','餐厅','餐厅','客厅','客厅','客厅','阳台'],
['厨房','厨房','餐厅','餐厅','客厅','客厅','客厅','阳台'],
['储物','玄关','玄关','玄关','客厅','客厅','客厅','阳台'],
['储物','储物','玄关','玄关','阳台','阳台','阳台','阳台'],
]
},
'three-bed': {
classic: [
['主卧','主卧','主卧','主卫','走廊','次卧1','次卧1','次卧2','次卧2','卫'],
['主卧','主卧','主卧','主卫','走廊','次卧1','次卧1','次卧2','次卧2','卫'],
['主卧','主卧','主卧','走廊','走廊','走廊','走廊','走廊','走廊','走廊'],
['厨房','厨房','餐厅','餐厅','走廊','客厅','客厅','客厅','客厅','阳台'],
['厨房','厨房','餐厅','餐厅','走廊','客厅','客厅','客厅','客厅','阳台'],
['玄关','玄关','餐厅','餐厅','走廊','客厅','客厅','客厅','客厅','阳台'],
['玄关','玄关','玄关','玄关','玄关','阳台','阳台','阳台','阳台','阳台'],
],
compact: [
['主卧','主卧','主卧','卫','次卧1','次卧1','次卧2','次卧2'],
['主卧','主卧','主卧','卫','次卧1','次卧1','次卧2','次卧2'],
['走廊','走廊','走廊','走廊','走廊','走廊','走廊','走廊'],
['厨房','餐厅','餐厅','客厅','客厅','客厅','客厅','阳台'],
['厨房','餐厅','餐厅','客厅','客厅','客厅','客厅','阳台'],
['玄关','玄关','玄关','客厅','客厅','阳台','阳台','阳台'],
],
luxury: [
['主卧','主卧','主卧','主卧','主卫','走廊','次卧1','次卧1','次卧2','次卧2','卫'],
['主卧','主卧','主卧','主卧','主卫','走廊','次卧1','次卧1','次卧2','次卧2','卫'],
['主卧','主卧','衣帽','衣帽','主卧','走廊','次卧1','次卧1','次卧2','次卧2','卫'],
['厨房','厨房','厨房','餐厅','餐厅','走廊','走廊','走廊','走廊','走廊','走廊'],
['厨房','厨房','厨房','餐厅','餐厅','客厅','客厅','客厅','客厅','客厅','阳台'],
['厨房','厨房','厨房','餐厅','餐厅','客厅','客厅','客厅','客厅','客厅','阳台'],
['储物','玄关','玄关','玄关','餐厅','客厅','客厅','客厅','客厅','客厅','阳台'],
['储物','储物','玄关','玄关','玄关','阳台','阳台','阳台','阳台','阳台','阳台'],
]
},
'three-bed-two': {
classic: [
['主卧','主卧','主卧','主卧','主卫','主卫','走廊','次卧1','次卧1','次卧2','次卧2','卫'],
['主卧','主卧','主卧','主卧','主卫','主卫','走廊','次卧1','次卧1','次卧2','次卧2','卫'],
['主卧','主卧','主卧','衣帽','衣帽','主卧','走廊','次卧1','次卧1','次卧2','次卧2','卫'],
['厨房','厨房','厨房','餐厅','餐厅','餐厅','走廊','走廊','走廊','走廊','走廊','走廊'],
['厨房','厨房','厨房','餐厅','餐厅','餐厅','客厅','客厅','客厅','客厅','客厅','阳台'],
['厨房','厨房','厨房','餐厅','餐厅','餐厅','客厅','客厅','客厅','客厅','客厅','阳台'],
['储物','玄关','玄关','玄关','餐厅','餐厅','客厅','客厅','客厅','客厅','客厅','阳台'],
['储物','储物','玄关','玄关','玄关','玄关','阳台','阳台','阳台','阳台','阳台','阳台'],
]
}
};
// ========== 邻接规则 (用于验证) ==========
// 必须邻接: 两房间必须共享一段墙
const MUST_ADJACENT = [
['厨房','餐厅'],
['玄关','客厅'],
['玄关','走廊'],
['主卫','主卧'],
];
// 允许邻接: 可以但不是必须
const ALLOW_ADJACENT = [
['客厅','餐厅'], ['客厅','走廊'], ['客厅','阳台'],
['主卧','走廊'], ['次卧','走廊'], ['次卧1','走廊'], ['次卧2','走廊'],
['卫','走廊'], ['卫生间','走廊'],
['厨房','玄关'], ['储物','玄关'], ['储物','厨房'],
['主卧','阳台'], ['次卧','阳台'], ['次卧1','阳台'], ['次卧2','阳台'],
['衣帽','主卧'],
];
// ========== 状态 ==========
let currentPlan = null;
let selectedRoom = null;
let seed = 0;
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const tooltip = document.getElementById('tooltip');
// ========== 伪随机生成器 ==========
function mulberry32(a) {
return function() {
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
}
}
let rng = mulberry32(Date.now());
// ========== 核心: 网格解析为房间 ==========
function parseGridToRooms(grid, cellW, cellH, offsetX, offsetY) {
const rows = grid.length, cols = grid[0].length;
const visited = Array(rows).fill(0).map(() => Array(cols).fill(false));
const rooms = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (visited[r][c] || grid[r][c] === '-') continue;
const type = grid[r][c];
// BFS找所有相连的同类型格子
const queue = [[r, c]];
visited[r][c] = true;
let minR = r, maxR = r, minC = c, maxC = c;
const cells = [];
while (queue.length) {
const [cr, cc] = queue.shift();
cells.push([cr, cc]);
minR = Math.min(minR, cr); maxR = Math.max(maxR, cr);
minC = Math.min(minC, cc); maxC = Math.max(maxC, cc);
for (const [dr, dc] of [[0,1],[0,-1],[1,0],[-1,0]]) {
const nr = cr+dr, nc = cc+dc;
if (nr>=0 && nr<rows && nc>=0 && nc<cols && !visited[nr][nc] && grid[nr][nc] === type) {
visited[nr][nc] = true;
queue.push([nr, nc]);
}
}
}
rooms.push({
type, cells,
gridMinR: minR, gridMaxR: maxR,
gridMinC: minC, gridMaxC: maxC,
x: offsetX + minC * cellW,
y: offsetY + minR * cellH,
w: (maxC - minC + 1) * cellW,
h: (maxR - minR + 1) * cellH,
gridW: maxC - minC + 1,
gridH: maxR - minR + 1
});
}
}
return rooms;
}
// ========== 应用变化 ==========
function applyVariation(grid, variation) {
const g = grid.map(row => [...row]);
if (variation === 'mirror') {
return g.map(row => [...row].reverse());
}
if (variation === 'swap') {
// 互换次卧1和次卧2
return g.map(row => row.map(c => {
if (c === '次卧1') return '次卧2';
if (c === '次卧2') return '次卧1';
return c;
}));
}
if (variation === 'auto') {
const options = ['normal', 'mirror', 'swap'];
const pick = options[Math.floor(rng() * options.length)];
return applyVariation(g, pick);
}
return g;
}
// ========== 邻接验证 ==========
function checkAdjacency(rooms, aType, bType) {
const a = rooms.find(r => r.type === aType);
const b = rooms.find(r => r.type === bType);
if (!a || !b) return true; // 房间不存在则跳过
// 检查两个房间是否共享一段墙
// 通过检查是否有相邻的格子
for (const [ar, ac] of a.cells) {
for (const [br, bc] of b.cells) {
if ((Math.abs(ar-br) === 1 && ac === bc) || (Math.abs(ac-bc) === 1 && ar === br)) {
return true;
}
}
}
return false;
}
function validateLayout(rooms) {
const issues = [];
for (const [a, b] of MUST_ADJACENT) {
const aExists = rooms.some(r => r.type === a);
const bExists = rooms.some(r => r.type === b);
if (aExists && bExists && !checkAdjacency(rooms, a, b)) {
issues.push(`${a} 应与 ${b} 相邻`);
}
}
// 检查阳台必须靠外墙
const balcony = rooms.find(r => r.type === '阳台');
if (balcony) {
// 外墙 = 至少一个边在网格边界
const plan = currentPlan;
const isOuter = balcony.gridMinR === 0 || balcony.gridMaxR === plan.gridRows - 1 ||
balcony.gridMinC === 0 || balcony.gridMaxC === plan.gridCols - 1;
if (!isOuter) issues.push('阳台未靠外墙');
}
return issues;
}
// ========== 生成布局 ==========
function generateLayout() {
const houseType = document.getElementById('houseType').value;
const templateName = document.getElementById('template').value;
const variation = document.getElementById('variation').value;
// 选择模板
let templates = TEMPLATES[houseType];
let grid = templates[templateName] || templates[Object.keys(templates)[0]];
if (!grid) {
// 如果该户型没有对应模板,用第一个
grid = Object.values(TEMPLATES)[0].classic;
}
// 应用变化
grid = applyVariation(grid, variation);
const gridRows = grid.length;
const gridCols = grid[0].length;
// 计算像素尺寸,留出边距
const padding = 50;
const availW = canvas.width - padding * 2;
const availH = canvas.height - padding * 2;
const cellW = availW / gridCols;
const cellH = availH / gridRows;
const cellSize = Math.min(cellW, cellH);
// 居中
const offsetX = (canvas.width - gridCols * cellSize) / 2;
const offsetY = (canvas.height - gridRows * cellSize) / 2;
const rooms = parseGridToRooms(grid, cellSize, cellSize, offsetX, offsetY);
// 计算面积 (假设每个格子 2.5㎡ 基准)
const cellArea = 2.5;
rooms.forEach(r => {
r.cellsCount = r.cells.length;
r.area = r.cellsCount * cellArea;
});
// 根据户型调整面积
const areaMultipliers = {
'one-bed': 1.8, 'two-bed': 2.2, 'three-bed': 2.4, 'three-bed-two': 2.8
};
const mult = areaMultipliers[houseType] || 2.2;
rooms.forEach(r => r.area = Math.round(r.cellsCount * mult));
currentPlan = {
rooms, grid, gridRows, gridCols,
cellSize, offsetX, offsetY,
houseType, templateName,
totalArea: rooms.reduce((s, r) => s + r.area, 0)
};
// 验证
const issues = validateLayout(rooms);
if (issues.length > 0) {
console.warn('布局问题:', issues);
}
return rooms;
}
// ========== 渲染 ==========
function draw() {
if (!currentPlan) return;
const { rooms, cellSize, offsetX, offsetY, gridRows, gridCols } = currentPlan;
const theme = THEMES[document.getElementById('theme').value] || THEMES.modern;
const showFurniture = document.getElementById('showFurniture').checked;
const showDimensions = document.getElementById('showDimensions').checked;
const showGrid = document.getElementById('showGrid').checked;
const showFlow = document.getElementById('showFlow').checked;
const showDoors = document.getElementById('showDoors').checked;
const showLabels = document.getElementById('showLabels').checked;
// 清空画布
ctx.fillStyle = '#fafafa';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 绘制网格
if (showGrid) {
ctx.strokeStyle = 'rgba(0,0,0,0.05)';
ctx.lineWidth = 1;
for (let i = 0; i <= gridCols; i++) {
ctx.beginPath();
ctx.moveTo(offsetX + i * cellSize, offsetY);
ctx.lineTo(offsetX + i * cellSize, offsetY + gridRows * cellSize);
ctx.stroke();
}
for (let i = 0; i <= gridRows; i++) {
ctx.beginPath();
ctx.moveTo(offsetX, offsetY + i * cellSize);
ctx.lineTo(offsetX + gridCols * cellSize, offsetY + i * cellSize);
ctx.stroke();
}
}
// 绘制房间填充
rooms.forEach(room => {
const color = theme[room.type] || '#ddd';
ctx.fillStyle = selectedRoom === room ? shadeColor(color, -15) : color;
ctx.fillRect(room.x, room.y, room.w, room.h);
// 阳台斜纹
if (room.type === '阳台') {
ctx.save();
ctx.beginPath();
ctx.rect(room.x, room.y, room.w, room.h);
ctx.clip();
ctx.strokeStyle = 'rgba(0,100,0,0.15)';
ctx.lineWidth = 1;
for (let i = -room.h; i < room.w + room.h; i += 10) {
ctx.beginPath();
ctx.moveTo(room.x + i, room.y);
ctx.lineTo(room.x + i - room.h, room.y + room.h);
ctx.stroke();
}
ctx.restore();
}
});
// 绘制墙体 (只在相邻不同房间或外墙处画)
ctx.strokeStyle = '#1a1a1a';
ctx.lineWidth = 3;
ctx.lineCap = 'square';
// 外墙
ctx.strokeRect(offsetX, offsetY, gridCols * cellSize, gridRows * cellSize);
// 内墙 (遍历格子边界)
ctx.lineWidth = 2;
const grid = currentPlan.grid;
for (let r = 0; r < gridRows; r++) {
for (let c = 0; c < gridCols; c++) {
const cur = grid[r][c];
if (cur === '-') continue;
// 右边界
if (c < gridCols - 1 && grid[r][c+1] !== cur && grid[r][c+1] !== '-') {
const x = offsetX + (c + 1) * cellSize;
const y = offsetY + r * cellSize;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x, y + cellSize);
ctx.stroke();
}
// 下边界
if (r < gridRows - 1 && grid[r+1][c] !== cur && grid[r+1][c] !== '-') {
const x = offsetX + c * cellSize;
const y = offsetY + (r + 1) * cellSize;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + cellSize, y);
ctx.stroke();
}
}
}
// 绘制门窗
if (showDoors) {
drawDoorsAndWindows(rooms);
}
// 绘制家具
if (showFurniture) {
rooms.forEach(room => drawFurniture(room, theme));
}
// 绘制标注
if (showLabels) {
rooms.forEach(room => {
const cx = room.x + room.w / 2;
const cy = room.y + room.h / 2;
ctx.fillStyle = '#2c3e50';
ctx.font = `bold ${Math.max(11, Math.min(16, room.w / 6))}px "Microsoft YaHei", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(room.type, cx, cy - 8);
ctx.font = `${Math.max(9, Math.min(12, room.w / 8))}px sans-serif`;
ctx.fillStyle = '#666';
ctx.fillText(`${room.area}㎡`, cx, cy + 10);
});
}
// 绘制尺寸
if (showDimensions) {
drawDimensions(rooms);
}
// 绘制动线
if (showFlow) {
drawFlowPath(rooms);
}
// 绘制指北针
drawCompass();
// 绘制比例尺
drawScale();
// 更新侧边栏
updateInfoPanel();
updateRoomList();
updateLegend(rooms, theme);
drawPieChart(rooms, theme);
}
// ========== 绘制门窗 ==========
function drawDoorsAndWindows(rooms) {
const { grid, cellSize, offsetX, offsetY, gridRows, gridCols } = currentPlan;
// 绘制门: 在相邻房间共享墙处画开门弧线
const doorPairs = [
['玄关','客厅'], ['玄关','走廊'], ['客厅','餐厅'], ['客厅','走廊'],
['走廊','主卧'], ['走廊','次卧'], ['走廊','次卧1'], ['走廊','次卧2'],
['走廊','卫'], ['走廊','卫生间'], ['主卧','主卫'], ['主卧','衣帽'],
['厨房','餐厅'], ['客厅','阳台'], ['主卧','阳台']
];
const drawnDoors = new Set();
for (const [a, b] of doorPairs) {
const ra = rooms.find(r => r.type === a);
const rb = rooms.find(r => r.type === b);
if (!ra || !rb) continue;
// 查找共享墙段
const segments = findSharedSegments(ra, rb);
for (const seg of segments) {
const key = `${Math.round(seg.x1)},${Math.round(seg.y1)}-${Math.round(seg.x2)},${Math.round(seg.y2)}`;
if (drawnDoors.has(key)) continue;
drawnDoors.add(key);
drawDoorArc(seg);
break; // 每对房间只画一个门
}
}
// 绘制窗户: 在靠外墙的客厅/卧室/阳台处
const windowRooms = rooms.filter(r =>
['客厅','主卧','次卧','次卧1','次卧2','阳台'].includes(r.type));
for (const room of windowRooms) {
drawWindows(room);
}
// 绘制入户门 (玄關外墙)
const entrance = rooms.find(r => r.type === '玄关');
if (entrance) {
drawEntranceDoor(entrance);
}
}
function findSharedSegments(ra, rb) {
const segments = [];
for (const [ar, ac] of ra.cells) {
for (const [br, bc] of rb.cells) {
if (ar === br && Math.abs(ac - bc) === 1) {
// 垂直共享墙
const wallC = Math.max(ac, bc);
const { cellSize, offsetX, offsetY } = currentPlan;
segments.push({
x1: offsetX + wallC * cellSize,
y1: offsetY + ar * cellSize,
x2: offsetX + wallC * cellSize,
y2: offsetY + (ar + 1) * cellSize,
vertical: true
});
} else if (ac === bc && Math.abs(ar - br) === 1) {
// 水平共享墙
const wallR = Math.max(ar, br);
const { cellSize, offsetX, offsetY } = currentPlan;
segments.push({
x1: offsetX + ac * cellSize,
y1: offsetY + wallR * cellSize,
x2: offsetX + (ac + 1) * cellSize,
y2: offsetY + wallR * cellSize,
vertical: false
});
}
}
}
return segments;
}
function drawDoorArc(seg) {
const doorLen = Math.min(25, Math.abs(seg.vertical ? seg.y2 - seg.y1 : seg.x2 - seg.x1) * 0.6);
ctx.save();
ctx.strokeStyle = '#555';
ctx.lineWidth = 1.5;
// 清除墙段
ctx.strokeStyle = '#fafafa';
ctx.lineWidth = 4;
ctx.beginPath();
if (seg.vertical) {
const mid = (seg.y1 + seg.y2) / 2;
ctx.moveTo(seg.x1, mid - doorLen/2);
ctx.lineTo(seg.x1, mid + doorLen/2);
} else {
const mid = (seg.x1 + seg.x2) / 2;
ctx.moveTo(mid - doorLen/2, seg.y1);
ctx.lineTo(mid + doorLen/2, seg.y1);
}
ctx.stroke();
// 画开门弧线
ctx.strokeStyle = '#555';
ctx.lineWidth = 1;
if (seg.vertical) {
const mid = (seg.y1 + seg.y2) / 2;
ctx.beginPath();
ctx.moveTo(seg.x1, mid - doorLen/2);
ctx.lineTo(seg.x1 + doorLen, mid - doorLen/2);
ctx.stroke();
ctx.beginPath();
ctx.arc(seg.x1, mid - doorLen/2, doorLen, 0, Math.PI/2);
ctx.stroke();
} else {
const mid = (seg.x1 + seg.x2) / 2;
ctx.beginPath();
ctx.moveTo(mid - doorLen/2, seg.y1);
ctx.lineTo(mid - doorLen/2, seg.y1 - doorLen);
ctx.stroke();
ctx.beginPath();
ctx.arc(mid - doorLen/2, seg.y1, doorLen, -Math.PI/2, 0);
ctx.stroke();
}
ctx.restore();
}
function drawWindows(room) {
const { cellSize, offsetX, offsetY, gridRows, gridCols } = currentPlan;
ctx.save();
ctx.strokeStyle = '#4FC3F7';
ctx.lineWidth = 3;
// 检查四条边是否靠外墙
if (room.gridMinR === 0) {
// 上边
const x = room.x + room.w * 0.25;
const w = room.w * 0.5;
const y = room.y;
ctx.fillStyle = '#fafafa';
ctx.fillRect(x - 2, y - 2, w + 4, 6);
ctx.beginPath();
ctx.moveTo(x, y); ctx.lineTo(x + w, y);
ctx.moveTo(x, y + 3); ctx.lineTo(x + w, y + 3);
ctx.stroke();
}
if (room.gridMaxR === gridRows - 1) {
const x = room.x + room.w * 0.25;
const w = room.w * 0.5;
const y = room.y + room.h;
ctx.fillStyle = '#fafafa';
ctx.fillRect(x - 2, y - 4, w + 4, 6);
ctx.beginPath();
ctx.moveTo(x, y); ctx.lineTo(x + w, y);
ctx.moveTo(x, y - 3); ctx.lineTo(x + w, y - 3);
ctx.stroke();
}
if (room.gridMinC === 0) {
const y = room.y + room.h * 0.25;
const h = room.h * 0.5;
const x = room.x;
ctx.fillStyle = '#fafafa';
ctx.fillRect(x - 2, y - 2, 6, h + 4);
ctx.beginPath();
ctx.moveTo(x, y); ctx.lineTo(x, y + h);
ctx.moveTo(x + 3, y); ctx.lineTo(x + 3, y + h);
ctx.stroke();
}
if (room.gridMaxC === gridCols - 1) {
const y = room.y + room.h * 0.25;
const h = room.h * 0.5;
const x = room.x + room.w;
ctx.fillStyle = '#fafafa';
ctx.fillRect(x - 4, y - 2, 6, h + 4);
ctx.beginPath();
ctx.moveTo(x, y); ctx.lineTo(x, y + h);
ctx.moveTo(x - 3, y); ctx.lineTo(x - 3, y + h);
ctx.stroke();
}
ctx.restore();
}
function drawEntranceDoor(entrance) {
const { gridRows, gridCols } = currentPlan;
ctx.save();
// 找到玄关靠外墙的一边
let x, y, w, h;
if (entrance.gridMaxR === gridRows - 1) {
// 底部外墙
x = entrance.x + entrance.w * 0.4;
y = entrance.y + entrance.h;
w = entrance.w * 0.3;
ctx.fillStyle = '#8B4513';
ctx.fillRect(x, y - 3, w, 6);
ctx.strokeStyle = '#8B4513';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x, y - w);
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, w, -Math.PI/2, 0);
ctx.stroke();
// 标记
ctx.fillStyle = '#8B4513';
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('入户门', x + w/2, y + 18);
} else if (entrance.gridMinC === 0) {
x = entrance.x;
y = entrance.y + entrance.h * 0.4;
h = entrance.h * 0.3;
ctx.fillStyle = '#8B4513';
ctx.fillRect(x - 3, y, 6, h);
ctx.strokeStyle = '#8B4513';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + h, y);
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, h, 0, Math.PI/2);
ctx.stroke();
}
ctx.restore();
}
// ========== 绘制家具 ==========
function drawFurniture(room, theme) {
ctx.save();
const cx = room.x + room.w / 2;
const cy = room.y + room.h / 2;
ctx.strokeStyle = 'rgba(80,80,80,0.6)';
ctx.fillStyle = 'rgba(200,200,200,0.4)';
ctx.lineWidth = 1;
switch(room.type) {
case '主卧':
// 双人床
drawRect(cx - room.w*0.25, cy - room.h*0.3, room.w*0.5, room.h*0.5);
// 枕头
drawRect(cx - room.w*0.2, cy - room.h*0.28, room.w*0.15, room.h*0.08);
drawRect(cx + room.w*0.05, cy - room.h*0.28, room.w*0.15, room.h*0.08);
// 床头柜
drawRect(cx - room.w*0.38, cy - room.h*0.25, room.w*0.1, room.h*0.12);
drawRect(cx + room.w*0.28, cy - room.h*0.25, room.w*0.1, room.h*0.12);
break;
case '次卧': case '次卧1': case '次卧2':
// 单人床
drawRect(cx - room.w*0.2, cy - room.h*0.3, room.w*0.4, room.h*0.5);
drawRect(cx - room.w*0.15, cy - room.h*0.28, room.w*0.3, room.h*0.08);
// 书桌
drawRect(cx + room.w*0.15, cy + room.h*0.1, room.w*0.3, room.h*0.15);
break;
case '客厅':
// 沙发 (L型)
drawRect(cx - room.w*0.35, cy + room.h*0.1, room.w*0.5, room.h*0.15);
drawRect(cx + room.w*0.15, cy - room.h*0.1, room.w*0.15, room.h*0.35);
// 茶几
drawRect(cx - room.w*0.12, cy - room.h*0.05, room.w*0.24, room.h*0.12);
// 电视柜
drawRect(cx - room.w*0.3, cy - room.h*0.35, room.w*0.6, room.h*0.08);
break;
case '餐厅':
// 餐桌
ctx.beginPath();
ctx.ellipse(cx, cy, room.w*0.22, room.h*0.2, 0, 0, Math.PI*2);
ctx.fill(); ctx.stroke();
// 椅子
for (let i = 0; i < 4; i++) {
const angle = (i / 4) * Math.PI * 2;
const px = cx + Math.cos(angle) * room.w * 0.32;
const py = cy + Math.sin(angle) * room.h * 0.28;
ctx.beginPath();
ctx.arc(px, py, 4, 0, Math.PI*2);
ctx.fill(); ctx.stroke();
}
break;
case '厨房':
// L型橱柜
drawRect(room.x + 4, room.y + 4, room.w*0.18, room.h - 8);
drawRect(room.x + 4, room.y + 4, room.w - 8, room.h*0.18);
// 灶台
ctx.beginPath();
ctx.arc(room.x + room.w*0.4, room.y + room.h*0.12, 4, 0, Math.PI*2);
ctx.arc(room.x + room.w*0.6, room.y + room.h*0.12, 4, 0, Math.PI*2);
ctx.fill(); ctx.stroke();
break;
case '卫生间': case '卫':
// 马桶
drawRect(room.x + room.w*0.6, room.y + room.h*0.5, room.w*0.25, room.h*0.35);
// 洗手台
drawRect(room.x + 4, room.y + 4, room.w*0.4, room.h*0.15);
// 淋浴
ctx.setLineDash([3, 3]);
drawRect(room.x + 4, room.y + room.h*0.5, room.w*0.35, room.h*0.4);
ctx.setLineDash([]);
break;
case '主卫':
// 浴缸
drawRect(room.x + 4, room.y + 4, room.w*0.6, room.h*0.3);
// 马桶
drawRect(room.x + room.w*0.65, room.y + room.h*0.5, room.w*0.25, room.h*0.35);
// 洗手台
drawRect(room.x + 4, room.y + room.h*0.5, room.w*0.4, room.h*0.15);
break;
case '书房':
// 书桌
drawRect(cx - room.w*0.3, cy - room.h*0.1, room.w*0.6, room.h*0.2);
// 椅子
ctx.beginPath();
ctx.arc(cx, cy + room.h*0.15, 6, 0, Math.PI*2);
ctx.fill(); ctx.stroke();
// 书架
drawRect(room.x + 4, room.y + 4, room.w*0.15, room.h - 8);
break;
case '阳台':
// 洗衣机
drawRect(room.x + room.w*0.1, room.y + room.h*0.2, room.w*0.25, room.h*0.4);
ctx.beginPath();
ctx.arc(room.x + room.w*0.225, room.y + room.h*0.4, room.w*0.08, 0, Math.PI*2);
ctx.stroke();
// 绿植
ctx.beginPath();
ctx.arc(room.x + room.w*0.7, room.y + room.h*0.5, 8, 0, Math.PI*2);
ctx.fillStyle = 'rgba(100,180,100,0.5)';
ctx.fill(); ctx.stroke();
break;
case '衣帽':
// 衣柜
drawRect(room.x + 4, room.y + 4, room.w - 8, room.h*0.2);
drawRect(room.x + 4, room.y + room.h*0.8 - 4, room.w - 8, room.h*0.2);
break;
case '储物':
// 架子
for (let i = 0; i < 3; i++) {
drawRect(room.x + 4, room.y + 4 + i * room.h*0.3, room.w*0.15, room.h*0.25);
}
break;
}
ctx.restore();
function drawRect(x, y, w, h) {
ctx.fillRect(x, y, w, h);
ctx.strokeRect(x, y, w, h);
}
}
// ========== 尺寸标注 ==========
function drawDimensions(rooms) {
const { cellSize } = currentPlan;
ctx.save();
ctx.strokeStyle = '#999';
ctx.fillStyle = '#666';
ctx.lineWidth = 0.8;
ctx.font = '9px sans-serif';
ctx.textAlign = 'center';
rooms.forEach(room => {
const wM = (room.gridW * 0.8).toFixed(1); // 假设每格0.8米宽
const hM = (room.gridH * 0.8).toFixed(1);
// 宽度
const dy = room.y - 8;
ctx.beginPath();
ctx.moveTo(room.x, dy);
ctx.lineTo(room.x + room.w, dy);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(room.x, dy - 3); ctx.lineTo(room.x, dy + 3);
ctx.moveTo(room.x + room.w, dy - 3); ctx.lineTo(room.x + room.w, dy + 3);
ctx.stroke();
ctx.fillStyle = '#fafafa';
ctx.fillRect(room.x + room.w/2 - 12, dy - 8, 24, 10);
ctx.fillStyle = '#666';
ctx.fillText(`${wM}m`, room.x + room.w/2, dy);
// 高度
const dx = room.x - 8;
ctx.beginPath();
ctx.moveTo(dx, room.y);
ctx.lineTo(dx, room.y + room.h);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(dx - 3, room.y); ctx.lineTo(dx + 3, room.y);
ctx.moveTo(dx - 3, room.y + room.h); ctx.lineTo(dx + 3, room.y + room.h);
ctx.stroke();
ctx.save();
ctx.translate(dx - 3, room.y + room.h/2);
ctx.rotate(-Math.PI/2);
ctx.fillStyle = '#fafafa';
ctx.fillRect(-12, -8, 24, 10);
ctx.fillStyle = '#666';
ctx.fillText(`${hM}m`, 0, 0);
ctx.restore();
});
ctx.restore();
}
// ========== 动线可视化 ==========
function drawFlowPath(rooms) {
const entrance = rooms.find(r => r.type === '玄关');
const living = rooms.find(r => r.type === '客厅');
if (!entrance || !living) return;
ctx.save();
ctx.strokeStyle = 'rgba(255, 87, 34, 0.6)';
ctx.lineWidth = 3;
ctx.setLineDash([8, 6]);
ctx.beginPath();
ctx.moveTo(entrance.x + entrance.w/2, entrance.y + entrance.h/2);
ctx.lineTo(living.x + living.w/2, living.y + living.h/2);
ctx.stroke();
// 箭头
const angle = Math.atan2(
living.y + living.h/2 - (entrance.y + entrance.h/2),
living.x + living.w/2 - (entrance.x + entrance.w/2)
);
const ax = living.x + living.w/2 - Math.cos(angle) * 15;
const ay = living.y + living.h/2 - Math.sin(angle) * 15;
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(255, 87, 34, 0.8)';
ctx.beginPath();
ctx.moveTo(living.x + living.w/2, living.y + living.h/2);
ctx.lineTo(ax - Math.sin(angle)*5, ay + Math.cos(angle)*5);
ctx.lineTo(ax + Math.sin(angle)*5, ay - Math.cos(angle)*5);
ctx.closePath();
ctx.fill();
// 标签
ctx.fillStyle = 'rgba(255, 87, 34, 0.9)';
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('主要动线', (entrance.x + living.x)/2 + entrance.w/2,
(entrance.y + living.y)/2 + entrance.h/2 - 10);
ctx.restore();
}
// ========== 指北针 ==========
function drawCompass() {
const cx = canvas.width - 50, cy = 50, r = 22;
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI*2);
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fill();
ctx.strokeStyle = '#333';
ctx.lineWidth = 2;
ctx.stroke();
// N 箭头
ctx.beginPath();
ctx.moveTo(cx, cy - r + 4);
ctx.lineTo(cx - 5, cy + 2);
ctx.lineTo(cx, cy - 2);
ctx.closePath();
ctx.fillStyle = '#E53935';
ctx.fill();
ctx.beginPath();
ctx.moveTo(cx, cy - r + 4);
ctx.lineTo(cx + 5, cy + 2);
ctx.lineTo(cx, cy - 2);
ctx.closePath();
ctx.fillStyle = '#FFCDD2';
ctx.fill();
ctx.fillStyle = '#E53935';
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('N', cx, cy - r - 6);
ctx.fillStyle = '#666';
ctx.fillText('S', cx, cy + r + 12);
ctx.restore();
}
// ========== 比例尺 ==========
function drawScale() {
const x = 30, y = canvas.height - 30;
const barW = 80;
ctx.save();
ctx.strokeStyle = '#333';
ctx.fillStyle = '#333';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, y); ctx.lineTo(x + barW, y);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x, y - 4); ctx.lineTo(x, y + 4);
ctx.moveTo(x + barW, y - 4); ctx.lineTo(x + barW, y + 4);
ctx.moveTo(x + barW/2, y - 2); ctx.lineTo(x + barW/2, y + 2);
ctx.stroke();
ctx.fillRect(x, y - 2, barW/2, 4);
ctx.font = '9px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('0', x, y + 14);
ctx.fillText('1m', x + barW/2, y + 14);
ctx.fillText('2m', x + barW, y + 14);
ctx.restore();
}
// ========== 信息面板更新 ==========
function updateInfoPanel() {
if (!currentPlan) return;
const { rooms, totalArea, houseType, templateName } = currentPlan;
const innerArea = Math.round(totalArea * 0.82);
const rate = ((innerArea / totalArea) * 100).toFixed(1);
const beds = rooms.filter(r => ['主卧','次卧','次卧1','次卧2'].includes(r.type)).length;
const baths = rooms.filter(r => ['卫','卫生间','主卫'].includes(r.type)).length;
document.getElementById('sTotal').textContent = `${totalArea}㎡`;
document.getElementById('sInner').textContent = `${innerArea}㎡`;
document.getElementById('sRate').textContent = `${rate}%`;
document.getElementById('sBed').textContent = `${beds}间`;
document.getElementById('sBath').textContent = `${baths}间`;
const layoutNames = {
'classic': '南北通透',
'compact': '紧凑实用',
'luxury': '豪华舒适'
};
document.getElementById('sLayout').textContent = layoutNames[templateName] || templateName;
}
function updateRoomList() {
if (!currentPlan) return;
const theme = THEMES[document.getElementById('theme').value];
const list = document.getElementById('roomList');
list.innerHTML = currentPlan.rooms.map(r => `
<div class="room-item ${selectedRoom === r ? 'active' : ''}" onclick="selectRoom('${r.type}')">
<span><span class="room-color" style="background:${theme[r.type]||'#ddd'}"></span>${r.type}</span>
<span class="badge">${r.area}㎡</span>
</div>
`).join('');
}
function updateLegend(rooms, theme) {
const types = [...new Set(rooms.map(r => r.type))];
document.getElementById('legend').innerHTML = types.map(t =>
`<div class="legend-item">
<span class="room-color" style="background:${theme[t]||'#ddd'}"></span>
<span>${t}</span>
</div>`
).join('');
}
// ========== 饼图 ==========
function drawPieChart(rooms, theme) {
const pieCanvas = document.getElementById('pieChart');
const pctx = pieCanvas.getContext('2d');
pctx.clearRect(0, 0, pieCanvas.width, pieCanvas.height);
const total = rooms.reduce((s, r) => s + r.area, 0);
const cx = 80, cy = 80, r = 60;
let start = -Math.PI / 2;
// 合并同类房间
const grouped = {};
rooms.forEach(room => {
const key = room.type.replace(/\d/g, '');
if (!grouped[key]) grouped[key] = { area: 0, color: theme[room.type] || '#ddd' };
grouped[key].area += room.area;
});
const entries = Object.entries(grouped);
entries.forEach(([name, data]) => {
const angle = (data.area / total) * Math.PI * 2;
pctx.beginPath();
pctx.moveTo(cx, cy);
pctx.arc(cx, cy, r, start, start + angle);
pctx.closePath();
pctx.fillStyle = data.color;
pctx.fill();
pctx.strokeStyle = '#fff';
pctx.lineWidth = 2;
pctx.stroke();
start += angle;
});
// 图例
pctx.font = '10px sans-serif';
pctx.textAlign = 'left';
pctx.textBaseline = 'middle';
entries.slice(0, 8).forEach(([name, data], i) => {
const lx = 155, ly = 10 + i * 18;
pctx.fillStyle = data.color;
pctx.fillRect(lx, ly, 10, 10);
pctx.strokeStyle = '#999';
pctx.lineWidth = 0.5;
pctx.strokeRect(lx, ly, 10, 10);
pctx.fillStyle = '#333';
const pct = ((data.area / total) * 100).toFixed(0);
pctx.fillText(`${name} ${pct}%`, lx + 15, ly + 5);
});
}
// ========== 房间选择 ==========
function selectRoom(type) {
selectedRoom = currentPlan.rooms.find(r => r.type === type) || null;
draw();
}
// ========== 颜色工具 ==========
function shadeColor(color, percent) {
const num = parseInt(color.replace('#',''), 16);
const amt = Math.round(2.55 * percent);
const R = Math.max(0, Math.min(255, (num >> 16) + amt));
const G = Math.max(0, Math.min(255, ((num >> 8) & 0x00FF) + amt));
const B = Math.max(0, Math.min(255, (num & 0x0000FF) + amt));
return '#' + (0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1);
}
// ========== 鼠标交互 ==========
canvas.addEventListener('mousemove', (e) => {
if (!currentPlan) return;
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
const my = (e.clientY - rect.top) * (canvas.height / rect.height);
const room = currentPlan.rooms.find(r =>
mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h);
if (room) {
tooltip.innerHTML = `<b>${room.type}</b><br>面积: ${room.area}㎡<br>尺寸: ${(room.gridW*0.8).toFixed(1)}×${(room.gridH*0.8).toFixed(1)}m`;
tooltip.style.left = (e.clientX - canvas.getBoundingClientRect().left + 10) + 'px';
tooltip.style.top = (e.clientY - canvas.getBoundingClientRect().top + 10) + 'px';
tooltip.classList.add('show');
canvas.style.cursor = 'pointer';
} else {
tooltip.classList.remove('show');
canvas.style.cursor = 'crosshair';
}
});
canvas.addEventListener('click', (e) => {
if (!currentPlan) return;
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
const my = (e.clientY - rect.top) * (canvas.height / rect.height);
const room = currentPlan.rooms.find(r =>
mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h);
selectedRoom = room || null;
draw();
});
// ========== 主控函数 ==========
function regenerate() {
seed = Math.floor(Math.random() * 1000000);
rng = mulberry32(seed);
document.getElementById('seedDisplay').textContent = '#' + seed.toString().padStart(6, '0');
selectedRoom = null;
generateLayout();
draw();
}
function exportPNG() {
const link = document.createElement('a');
link.download = `户型图_${currentPlan ? currentPlan.totalArea : 'plan'}㎡_seed${seed}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
}
function saveLayout() {
if (!currentPlan) return;
const data = {
seed, houseType: document.getElementById('houseType').value,
template: document.getElementById('template').value,
variation: document.getElementById('variation').value,
theme: document.getElementById('theme').value,
timestamp: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const link = document.createElement('a');
link.download = `户型方案_seed${seed}.json`;
link.href = URL.createObjectURL(blob);
link.click();
}
function loadLayout() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target.result);
seed = data.seed;
rng = mulberry32(seed);
document.getElementById('houseType').value = data.houseType;
document.getElementById('template').value = data.template;
document.getElementById('variation').value = data.variation;
document.getElementById('theme').value = data.theme;
document.getElementById('seedDisplay').textContent = '#' + seed.toString().padStart(6, '0');
generateLayout();
draw();
} catch (err) {
alert('文件解析失败: ' + err.message);
}
};
reader.readAsText(file);
};
input.click();
}
// 监听变化
document.querySelectorAll('select, input[type="checkbox"]').forEach(el => {
el.addEventListener('change', () => {
if (el.id === 'houseType' || el.id === 'template' || el.id === 'variation') {
generateLayout();
}
draw();
});
});
// 初始化
regenerate();
</script>
</body>
</html>
index.html
index.html