|
<!DOCTYPE html> |
|
<html lang="zh"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>ER图生成器</title> |
|
<style> |
|
body { |
|
font-family: Arial, sans-serif; |
|
background-color: #f5f5f5; |
|
margin: 0; |
|
padding: 0; |
|
overflow: hidden; |
|
} |
|
.container { |
|
display: flex; |
|
height: 100vh; |
|
} |
|
.editor-panel { |
|
flex: 0 0 40%; |
|
padding: 20px; |
|
background-color: white; |
|
border-right: 1px solid #ddd; |
|
display: flex; |
|
flex-direction: column; |
|
overflow: auto; |
|
} |
|
.preview-panel { |
|
flex: 0 0 60%; |
|
background-color: white; |
|
overflow: auto; |
|
} |
|
h1, h2, h3 { |
|
color: #333; |
|
margin-top: 0; |
|
} |
|
.title-bar { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
margin-bottom: 15px; |
|
padding-bottom: 15px; |
|
border-bottom: 1px solid #eee; |
|
} |
|
textarea { |
|
flex: 1; |
|
padding: 10px; |
|
font-family: monospace; |
|
border: 1px solid #ddd; |
|
border-radius: 4px; |
|
resize: none; |
|
min-height: 300px; |
|
} |
|
button { |
|
background-color: #4CAF50; |
|
color: white; |
|
border: none; |
|
padding: 10px 15px; |
|
border-radius: 4px; |
|
cursor: pointer; |
|
margin-top: 15px; |
|
font-size: 16px; |
|
} |
|
button:hover { |
|
background-color: #45a049; |
|
} |
|
.syntax-guide { |
|
margin-top: 20px; |
|
background-color: #f9f9f9; |
|
padding: 15px; |
|
border-radius: 4px; |
|
font-size: 14px; |
|
} |
|
pre { |
|
white-space: pre-wrap; |
|
margin: 0; |
|
font-family: monospace; |
|
font-size: 14px; |
|
} |
|
input[type="text"] { |
|
padding: 8px 10px; |
|
border: 1px solid #ddd; |
|
border-radius: 4px; |
|
font-size: 16px; |
|
width: 300px; |
|
} |
|
.preview-controls { |
|
padding: 20px; |
|
border-bottom: 1px solid #eee; |
|
} |
|
#svgContainer { |
|
padding: 20px; |
|
} |
|
svg { |
|
display: block; |
|
margin: 0 auto; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<div class="container"> |
|
<div class="editor-panel"> |
|
<div class="title-bar"> |
|
<h2>ER图代码编辑器</h2> |
|
</div> |
|
<textarea id="erCode" rows="20">// 实体定义 |
|
entity: 员工 |
|
pk: 员工编号 |
|
attr: 姓名 |
|
attr: 性别 |
|
attr: 出生日期 |
|
attr: 电话 |
|
|
|
entity: 部门 |
|
pk: 部门编号 |
|
attr: 部门名称 |
|
attr: 位置 |
|
|
|
entity: 项目 |
|
pk: 项目编号 |
|
attr: 项目名称 |
|
attr: 开始日期 |
|
attr: 结束日期 |
|
attr: 预算 |
|
|
|
entity: 客户 |
|
pk: 客户编号 |
|
attr: 客户名称 |
|
attr: 联系人 |
|
attr: 电话 |
|
|
|
relation: 隶属 |
|
from: 员工 (N) |
|
to: 部门 (1) |
|
|
|
relation: 管理 |
|
from: 员工 (1) |
|
to: 项目 (N) |
|
|
|
relation: 参与 |
|
from: 员工 (N) |
|
to: 项目 (N) |
|
|
|
relation: 合作 |
|
from: 项目 (N) |
|
to: 客户 (1)</textarea> |
|
<button id="generateBtn">生成ER图</button> |
|
<div class="syntax-guide"> |
|
<h3>语法说明:</h3> |
|
<pre>// 实体定义 |
|
entity: 实体名 |
|
pk: 主键字段 |
|
attr: 属性1 |
|
attr: 属性2 |
|
|
|
// 关系定义 |
|
relation: 关系名 |
|
from: 实体1 (基数) |
|
to: 实体2 (基数) |
|
|
|
// 基数表示: 1, N, M等</pre> |
|
</div> |
|
</div> |
|
<div class="preview-panel"> |
|
<div class="preview-controls"> |
|
<div class="title-bar"> |
|
<h2>ER图预览</h2> |
|
<div> |
|
<span>标题: </span> |
|
<input type="text" id="diagramTitle" value="企业人事管理系统"> |
|
</div> |
|
</div> |
|
</div> |
|
<div id="svgContainer"></div> |
|
</div> |
|
</div> |
|
|
|
<script> |
|
|
|
function parseERCode(code) { |
|
const entities = []; |
|
const relations = []; |
|
|
|
let currentEntity = null; |
|
let currentRelation = null; |
|
|
|
const lines = code.split('\n'); |
|
|
|
for (const line of lines) { |
|
const trimmedLine = line.trim(); |
|
if (trimmedLine === '' || trimmedLine.startsWith('//')) continue; |
|
|
|
|
|
if (trimmedLine.startsWith('entity:')) { |
|
if (currentEntity) entities.push(currentEntity); |
|
if (currentRelation) relations.push(currentRelation); |
|
|
|
currentEntity = { |
|
name: trimmedLine.substring(7).trim(), |
|
pk: null, |
|
attributes: [] |
|
}; |
|
currentRelation = null; |
|
} |
|
|
|
else if (trimmedLine.startsWith('pk:') && currentEntity) { |
|
currentEntity.pk = trimmedLine.substring(3).trim(); |
|
} |
|
|
|
else if (trimmedLine.startsWith('attr:') && currentEntity) { |
|
currentEntity.attributes.push(trimmedLine.substring(5).trim()); |
|
} |
|
|
|
else if (trimmedLine.startsWith('relation:')) { |
|
if (currentEntity) entities.push(currentEntity); |
|
if (currentRelation) relations.push(currentRelation); |
|
|
|
currentEntity = null; |
|
currentRelation = { |
|
name: trimmedLine.substring(9).trim(), |
|
from: null, |
|
to: null |
|
}; |
|
} |
|
|
|
else if (trimmedLine.startsWith('from:') && currentRelation) { |
|
const match = trimmedLine.match(/from:\s+([^\(]+)\s+\(([^\)]+)\)/); |
|
if (match) { |
|
currentRelation.from = { |
|
entity: match[1].trim(), |
|
cardinality: match[2].trim() |
|
}; |
|
} |
|
} |
|
|
|
else if (trimmedLine.startsWith('to:') && currentRelation) { |
|
const match = trimmedLine.match(/to:\s+([^\(]+)\s+\(([^\)]+)\)/); |
|
if (match) { |
|
currentRelation.to = { |
|
entity: match[1].trim(), |
|
cardinality: match[2].trim() |
|
}; |
|
} |
|
} |
|
} |
|
|
|
|
|
if (currentEntity) entities.push(currentEntity); |
|
if (currentRelation) relations.push(currentRelation); |
|
|
|
return { entities, relations }; |
|
} |
|
|
|
|
|
function createLayout(entities, relations) { |
|
|
|
const minWidth = 1200; |
|
const minHeight = 900; |
|
const width = Math.max(minWidth, entities.length * 300); |
|
const height = Math.max(minHeight, entities.length * 250); |
|
|
|
const layout = { |
|
width: width, |
|
height: height, |
|
entities: {}, |
|
relations: {} |
|
}; |
|
|
|
|
|
if (entities.length <= 4) { |
|
|
|
const positions = [ |
|
{ x: width / 2, y: height * 0.25 }, |
|
{ x: width * 0.25, y: height / 2 }, |
|
{ x: width * 0.75, y: height / 2 }, |
|
{ x: width / 2, y: height * 0.75 } |
|
]; |
|
|
|
entities.forEach((entity, index) => { |
|
layout.entities[entity.name] = { |
|
x: positions[index].x, |
|
y: positions[index].y, |
|
width: 120, |
|
height: 60, |
|
attributes: [], |
|
section: index |
|
}; |
|
}); |
|
} else { |
|
|
|
const centerX = width / 2; |
|
const centerY = height / 2; |
|
const radius = Math.min(width, height) * 0.3; |
|
|
|
entities.forEach((entity, index) => { |
|
const angle = (index * 2 * Math.PI) / entities.length; |
|
const x = centerX + radius * Math.cos(angle); |
|
const y = centerY + radius * Math.sin(angle); |
|
|
|
|
|
let section; |
|
if (y < centerY && Math.abs(x - centerX) < radius * 0.5) section = 0; |
|
else if (x < centerX && Math.abs(y - centerY) < radius * 0.5) section = 1; |
|
else if (x > centerX && Math.abs(y - centerY) < radius * 0.5) section = 2; |
|
else section = 3; |
|
|
|
layout.entities[entity.name] = { |
|
x: x, |
|
y: y, |
|
width: 120, |
|
height: 60, |
|
attributes: [], |
|
section: section |
|
}; |
|
}); |
|
} |
|
|
|
|
|
const occupiedSpaces = []; |
|
|
|
|
|
for (const [entityName, entityLayout] of Object.entries(layout.entities)) { |
|
occupiedSpaces.push({ |
|
x: entityLayout.x, |
|
y: entityLayout.y, |
|
width: entityLayout.width + 20, |
|
height: entityLayout.height + 20, |
|
type: 'entity' |
|
}); |
|
} |
|
|
|
|
|
for (const [entityName, entityLayout] of Object.entries(layout.entities)) { |
|
const entity = entities.find(e => e.name === entityName); |
|
if (!entity) continue; |
|
|
|
|
|
const allAttributes = []; |
|
if (entity.pk) { |
|
allAttributes.push({ name: entity.pk, isPk: true }); |
|
} |
|
|
|
entity.attributes.forEach(attr => { |
|
allAttributes.push({ name: attr, isPk: false }); |
|
}); |
|
|
|
|
|
let preferredDirections = []; |
|
|
|
switch(entityLayout.section) { |
|
case 0: |
|
preferredDirections = ['top', 'left', 'right', 'bottom']; |
|
break; |
|
case 1: |
|
preferredDirections = ['left', 'top', 'bottom', 'right']; |
|
break; |
|
case 2: |
|
preferredDirections = ['right', 'top', 'bottom', 'left']; |
|
break; |
|
case 3: |
|
preferredDirections = ['bottom', 'left', 'right', 'top']; |
|
break; |
|
} |
|
|
|
|
|
let pkIndex = allAttributes.findIndex(attr => attr.isPk); |
|
if (pkIndex >= 0) { |
|
const pk = allAttributes[pkIndex]; |
|
const bestDirection = findBestDirection(entityLayout, pk, preferredDirections[0], occupiedSpaces); |
|
|
|
|
|
const pkPosition = positionInDirection(entityLayout, bestDirection, 120); |
|
|
|
entityLayout.pk = { |
|
x: pkPosition.x, |
|
y: pkPosition.y, |
|
width: 60, |
|
height: 35, |
|
name: pk.name |
|
}; |
|
|
|
|
|
occupiedSpaces.push({ |
|
x: pkPosition.x, |
|
y: pkPosition.y, |
|
width: 130, |
|
height: 80, |
|
type: 'pk' |
|
}); |
|
|
|
|
|
allAttributes.splice(pkIndex, 1); |
|
} |
|
|
|
|
|
allAttributes.forEach((attr, i) => { |
|
|
|
let bestDirection = null; |
|
let bestPosition = null; |
|
|
|
|
|
for (const direction of preferredDirections) { |
|
|
|
for (let offset = 0; offset <= 4; offset++) { |
|
const distance = 120 + offset * 20; |
|
const angleOffset = (offset * 0.1) * (i % 2 === 0 ? 1 : -1); |
|
|
|
const position = positionInDirection(entityLayout, direction, distance, angleOffset); |
|
|
|
|
|
if (!hasOverlap(position, occupiedSpaces)) { |
|
bestDirection = direction; |
|
bestPosition = position; |
|
break; |
|
} |
|
} |
|
|
|
if (bestPosition) break; |
|
} |
|
|
|
|
|
if (!bestPosition) { |
|
const lastDirection = preferredDirections[preferredDirections.length - 1]; |
|
bestPosition = positionInDirection(entityLayout, lastDirection, 200 + i * 30); |
|
} |
|
|
|
|
|
const attrObj = { |
|
x: bestPosition.x, |
|
y: bestPosition.y, |
|
width: 55, |
|
height: 30, |
|
name: attr.name |
|
}; |
|
|
|
entityLayout.attributes.push(attrObj); |
|
|
|
|
|
occupiedSpaces.push({ |
|
x: bestPosition.x, |
|
y: bestPosition.y, |
|
width: 120, |
|
height: 70, |
|
type: 'attr' |
|
}); |
|
}); |
|
} |
|
|
|
|
|
relations.forEach(relation => { |
|
const fromEntity = layout.entities[relation.from.entity]; |
|
const toEntity = layout.entities[relation.to.entity]; |
|
|
|
if (fromEntity && toEntity) { |
|
|
|
const dx = toEntity.x - fromEntity.x; |
|
const dy = toEntity.y - fromEntity.y; |
|
const distance = Math.sqrt(dx * dx + dy * dy); |
|
|
|
|
|
const ratio = 0.4 + (distance / 1500); |
|
const midX = fromEntity.x + dx * ratio; |
|
const midY = fromEntity.y + dy * ratio; |
|
|
|
|
|
let bestX = midX; |
|
let bestY = midY; |
|
let minOverlaps = Number.MAX_VALUE; |
|
|
|
|
|
for (let offsetX = -60; offsetX <= 60; offsetX += 20) { |
|
for (let offsetY = -60; offsetY <= 60; offsetY += 20) { |
|
const testX = midX + offsetX; |
|
const testY = midY + offsetY; |
|
|
|
|
|
let overlaps = 0; |
|
for (const space of occupiedSpaces) { |
|
if (isPointInRect(testX, testY, space)) { |
|
overlaps++; |
|
} |
|
} |
|
|
|
|
|
if (overlaps < minOverlaps) { |
|
minOverlaps = overlaps; |
|
bestX = testX; |
|
bestY = testY; |
|
|
|
|
|
if (overlaps === 0) break; |
|
} |
|
} |
|
if (minOverlaps === 0) break; |
|
} |
|
|
|
|
|
if (minOverlaps > 0) { |
|
const perpX = -dy / distance * 100; |
|
const perpY = dx / distance * 100; |
|
|
|
|
|
bestX = midX + perpX; |
|
bestY = midY + perpY; |
|
|
|
|
|
let stillOverlap = false; |
|
for (const space of occupiedSpaces) { |
|
if (isPointInRect(bestX, bestY, space)) { |
|
stillOverlap = true; |
|
break; |
|
} |
|
} |
|
|
|
|
|
if (stillOverlap) { |
|
bestX = midX - perpX; |
|
bestY = midY - perpY; |
|
} |
|
} |
|
|
|
|
|
layout.relations[relation.name] = { |
|
x: bestX, |
|
y: bestY, |
|
width: 45, |
|
height: 25, |
|
from: relation.from, |
|
to: relation.to |
|
}; |
|
|
|
|
|
occupiedSpaces.push({ |
|
x: bestX, |
|
y: bestY, |
|
width: 100, |
|
height: 60, |
|
type: 'relation' |
|
}); |
|
} |
|
}); |
|
|
|
return layout; |
|
} |
|
|
|
|
|
function hasOverlap(position, occupiedSpaces) { |
|
const testRect = { |
|
x: position.x, |
|
y: position.y, |
|
width: 120, |
|
height: 70 |
|
}; |
|
|
|
for (const space of occupiedSpaces) { |
|
if (doRectsOverlap(testRect, space)) { |
|
return true; |
|
} |
|
} |
|
|
|
return false; |
|
} |
|
|
|
|
|
function doRectsOverlap(rect1, rect2) { |
|
const minDistance = (rect1.width + rect2.width) / 2 * 0.8; |
|
const minVertical = (rect1.height + rect2.height) / 2 * 0.8; |
|
|
|
const dx = Math.abs(rect1.x - rect2.x); |
|
const dy = Math.abs(rect1.y - rect2.y); |
|
|
|
return dx < minDistance && dy < minVertical; |
|
} |
|
|
|
|
|
function isPointInRect(x, y, rect) { |
|
const halfWidth = rect.width / 2; |
|
const halfHeight = rect.height / 2; |
|
|
|
return Math.abs(x - rect.x) < halfWidth && |
|
Math.abs(y - rect.y) < halfHeight; |
|
} |
|
|
|
|
|
function positionInDirection(entityLayout, direction, distance, angleOffset = 0) { |
|
let angle; |
|
|
|
switch(direction) { |
|
case 'top': |
|
angle = -Math.PI/2 + (angleOffset || 0); |
|
break; |
|
case 'right': |
|
angle = 0 + (angleOffset || 0); |
|
break; |
|
case 'bottom': |
|
angle = Math.PI/2 + (angleOffset || 0); |
|
break; |
|
case 'left': |
|
angle = Math.PI + (angleOffset || 0); |
|
break; |
|
} |
|
|
|
return { |
|
x: entityLayout.x + Math.cos(angle) * distance, |
|
y: entityLayout.y + Math.sin(angle) * distance |
|
}; |
|
} |
|
|
|
|
|
function findBestDirection(entityLayout, attr, preferredDirection, occupiedSpaces) { |
|
const directions = ['top', 'right', 'bottom', 'left']; |
|
let bestDirection = preferredDirection; |
|
let minOverlaps = Number.MAX_VALUE; |
|
|
|
|
|
for (const direction of directions) { |
|
const position = positionInDirection(entityLayout, direction, 120); |
|
|
|
|
|
let overlaps = 0; |
|
for (const space of occupiedSpaces) { |
|
if (isPointInRect(position.x, position.y, space)) { |
|
overlaps++; |
|
} |
|
} |
|
|
|
|
|
const directionPenalty = (direction === preferredDirection) ? 0 : 1; |
|
const score = overlaps + directionPenalty; |
|
|
|
if (score < minOverlaps) { |
|
minOverlaps = score; |
|
bestDirection = direction; |
|
} |
|
} |
|
|
|
return bestDirection; |
|
} |
|
|
|
|
|
function calculateConnectionPoint(entity, relation) { |
|
const dx = relation.x - entity.x; |
|
const dy = relation.y - entity.y; |
|
const angle = Math.atan2(dy, dx); |
|
|
|
|
|
let intersectX, intersectY; |
|
|
|
|
|
const width = entity.width / 2; |
|
const height = entity.height / 2; |
|
|
|
|
|
if (Math.abs(dx) < 0.001) { |
|
intersectX = entity.x; |
|
intersectY = entity.y + Math.sign(dy) * height; |
|
} else if (Math.abs(dy) < 0.001) { |
|
intersectX = entity.x + Math.sign(dx) * width; |
|
intersectY = entity.y; |
|
} else { |
|
|
|
const slope = dy / dx; |
|
const invSlope = dx / dy; |
|
|
|
|
|
const vertX = entity.x + Math.sign(dx) * width; |
|
const vertY = entity.y + slope * (vertX - entity.x); |
|
|
|
|
|
const horizY = entity.y + Math.sign(dy) * height; |
|
const horizX = entity.x + invSlope * (horizY - entity.y); |
|
|
|
|
|
const dxVert = Math.abs(vertX - entity.x); |
|
const dyVert = Math.abs(vertY - entity.y); |
|
const dVert = Math.sqrt(dxVert * dxVert + dyVert * dyVert); |
|
|
|
const dxHoriz = Math.abs(horizX - entity.x); |
|
const dyHoriz = Math.abs(horizY - entity.y); |
|
const dHoriz = Math.sqrt(dxHoriz * dxHoriz + dyHoriz * dyHoriz); |
|
|
|
if (dVert <= dHoriz && Math.abs(vertY - entity.y) <= height) { |
|
intersectX = vertX; |
|
intersectY = vertY; |
|
} else if (Math.abs(horizX - entity.x) <= width) { |
|
intersectX = horizX; |
|
intersectY = horizY; |
|
} else { |
|
|
|
intersectX = entity.x + Math.sign(dx) * width; |
|
intersectY = entity.y + Math.sign(dy) * height; |
|
} |
|
} |
|
|
|
return { x: intersectX, y: intersectY }; |
|
} |
|
|
|
|
|
function calculateEntityToAttributeConnection(entity, attribute) { |
|
const dx = attribute.x - entity.x; |
|
const dy = attribute.y - entity.y; |
|
const angle = Math.atan2(dy, dx); |
|
|
|
|
|
const width = entity.width / 2; |
|
const height = entity.height / 2; |
|
|
|
|
|
if (Math.abs(dx) < 0.001) { |
|
return { |
|
x: entity.x, |
|
y: entity.y + Math.sign(dy) * height |
|
}; |
|
} else if (Math.abs(dy) < 0.001) { |
|
return { |
|
x: entity.x + Math.sign(dx) * width, |
|
y: entity.y |
|
}; |
|
} else { |
|
|
|
const slope = dy / dx; |
|
|
|
|
|
const tx = width / Math.abs(Math.cos(angle)); |
|
const ty = height / Math.abs(Math.sin(angle)); |
|
|
|
if (tx < ty) { |
|
return { |
|
x: entity.x + Math.sign(dx) * width, |
|
y: entity.y + slope * Math.sign(dx) * width |
|
}; |
|
} else { |
|
return { |
|
x: entity.x + Math.sign(dy) * height / slope, |
|
y: entity.y + Math.sign(dy) * height |
|
}; |
|
} |
|
} |
|
} |
|
|
|
|
|
function calculateAttributeToEntityConnection(attribute, entity) { |
|
const dx = entity.x - attribute.x; |
|
const dy = entity.y - attribute.y; |
|
const angle = Math.atan2(dy, dx); |
|
|
|
|
|
return { |
|
x: attribute.x + Math.cos(angle) * attribute.width, |
|
y: attribute.y + Math.sin(angle) * attribute.height |
|
}; |
|
} |
|
|
|
|
|
function generateSVG(entities, relations, layout, title) { |
|
|
|
let svgContent = ` |
|
<svg width="${layout.width}" height="${layout.height}" xmlns="http://www.w3.org/2000/svg"> |
|
`; |
|
|
|
|
|
svgContent += ` |
|
<text x="${layout.width/2}" y="40" text-anchor="middle" font-size="24" font-weight="bold" fill="#333">${title}</text> |
|
`; |
|
|
|
|
|
|
|
let linesLayer = '<g id="lines-layer">\n'; |
|
|
|
|
|
for (const [relationName, relationLayout] of Object.entries(layout.relations)) { |
|
const fromEntity = layout.entities[relationLayout.from.entity]; |
|
const toEntity = layout.entities[relationLayout.to.entity]; |
|
|
|
if (fromEntity && toEntity) { |
|
|
|
const fromConnection = calculateConnectionPoint(fromEntity, relationLayout); |
|
const toConnection = calculateConnectionPoint(toEntity, relationLayout); |
|
|
|
|
|
linesLayer += ` |
|
<line x1="${fromConnection.x}" y1="${fromConnection.y}" x2="${relationLayout.x}" y2="${relationLayout.y}" |
|
stroke="#6c757d" stroke-width="2" /> |
|
<line x1="${relationLayout.x}" y1="${relationLayout.y}" x2="${toConnection.x}" y2="${toConnection.y}" |
|
stroke="#6c757d" stroke-width="2" /> |
|
`; |
|
|
|
|
|
const fromCardX = fromConnection.x + (relationLayout.x - fromConnection.x) * 0.25; |
|
const fromCardY = fromConnection.y + (relationLayout.y - fromConnection.y) * 0.25; |
|
|
|
const toCardX = toConnection.x + (relationLayout.x - toConnection.x) * 0.25; |
|
const toCardY = toConnection.y + (relationLayout.y - toConnection.y) * 0.25; |
|
|
|
|
|
linesLayer += ` |
|
<text x="${fromCardX}" y="${fromCardY}" text-anchor="middle" dominant-baseline="middle" |
|
font-weight="bold" font-size="16" fill="#dc3545">${relationLayout.from.cardinality}</text> |
|
<text x="${toCardX}" y="${toCardY}" text-anchor="middle" dominant-baseline="middle" |
|
font-weight="bold" font-size="16" fill="#dc3545">${relationLayout.to.cardinality}</text> |
|
`; |
|
} |
|
} |
|
|
|
|
|
for (const [entityName, entityLayout] of Object.entries(layout.entities)) { |
|
|
|
if (entityLayout.pk) { |
|
const pk = entityLayout.pk; |
|
|
|
|
|
const entityConnection = calculateEntityToAttributeConnection(entityLayout, pk); |
|
const pkConnection = calculateAttributeToEntityConnection(pk, entityLayout); |
|
|
|
linesLayer += ` |
|
<line x1="${entityConnection.x}" y1="${entityConnection.y}" x2="${pkConnection.x}" y2="${pkConnection.y}" |
|
stroke="#dc3545" stroke-width="1.5" /> |
|
`; |
|
} |
|
|
|
|
|
entityLayout.attributes.forEach(attr => { |
|
|
|
const entityConnection = calculateEntityToAttributeConnection(entityLayout, attr); |
|
const attrConnection = calculateAttributeToEntityConnection(attr, entityLayout); |
|
|
|
linesLayer += ` |
|
<line x1="${entityConnection.x}" y1="${entityConnection.y}" x2="${attrConnection.x}" y2="${attrConnection.y}" |
|
stroke="#28a745" stroke-width="1.5" /> |
|
`; |
|
}); |
|
} |
|
|
|
linesLayer += '</g>\n'; |
|
|
|
|
|
let relationshipsLayer = '<g id="relationships-layer">\n'; |
|
|
|
for (const [relationName, relationLayout] of Object.entries(layout.relations)) { |
|
const x = relationLayout.x; |
|
const y = relationLayout.y; |
|
const w = relationLayout.width; |
|
const h = relationLayout.height; |
|
|
|
relationshipsLayer += ` |
|
<polygon points="${x},${y-h} ${x+w},${y} ${x},${y+h} ${x-w},${y}" |
|
fill="#fff3cd" stroke="#ffc107" stroke-width="1.5" /> |
|
<text x="${x}" y="${y}" text-anchor="middle" dominant-baseline="middle" |
|
font-size="14" fill="#212529">${relationName}</text> |
|
`; |
|
} |
|
|
|
relationshipsLayer += '</g>\n'; |
|
|
|
|
|
let entitiesLayer = '<g id="entities-layer">\n'; |
|
|
|
for (const [entityName, entityLayout] of Object.entries(layout.entities)) { |
|
|
|
|
|
if (entityLayout.pk) { |
|
const pk = entityLayout.pk; |
|
entitiesLayer += ` |
|
<ellipse cx="${pk.x}" cy="${pk.y}" rx="${pk.width}" ry="${pk.height}" |
|
fill="#ffebee" stroke="#dc3545" stroke-width="2" /> |
|
<text x="${pk.x}" y="${pk.y}" text-anchor="middle" dominant-baseline="middle" |
|
font-size="14" font-weight="bold">${pk.name}</text> |
|
`; |
|
} |
|
|
|
|
|
entityLayout.attributes.forEach(attr => { |
|
entitiesLayer += ` |
|
<ellipse cx="${attr.x}" cy="${attr.y}" rx="${attr.width}" ry="${attr.height}" |
|
fill="#f8f9fa" stroke="#28a745" stroke-width="1.5" /> |
|
<text x="${attr.x}" y="${attr.y}" text-anchor="middle" dominant-baseline="middle" |
|
font-size="14">${attr.name}</text> |
|
`; |
|
}); |
|
|
|
|
|
entitiesLayer += ` |
|
<rect x="${entityLayout.x - entityLayout.width/2}" y="${entityLayout.y - entityLayout.height/2}" |
|
width="${entityLayout.width}" height="${entityLayout.height}" rx="5" ry="5" |
|
fill="#e3f2fd" stroke="#0d6efd" stroke-width="2" /> |
|
<text x="${entityLayout.x}" y="${entityLayout.y}" text-anchor="middle" dominant-baseline="middle" |
|
font-size="16" font-weight="bold" fill="#0d6efd">${entityName}</text> |
|
`; |
|
} |
|
|
|
entitiesLayer += '</g>\n'; |
|
|
|
|
|
svgContent += linesLayer; |
|
svgContent += relationshipsLayer; |
|
svgContent += entitiesLayer; |
|
|
|
svgContent += '</svg>'; |
|
return svgContent; |
|
} |
|
|
|
|
|
function generateERDiagram() { |
|
const erCode = document.getElementById('erCode').value; |
|
const title = document.getElementById('diagramTitle').value || 'ER图'; |
|
|
|
try { |
|
const { entities, relations } = parseERCode(erCode); |
|
const layout = createLayout(entities, relations); |
|
const svgContent = generateSVG(entities, relations, layout, title); |
|
|
|
document.getElementById('svgContainer').innerHTML = svgContent; |
|
} catch (error) { |
|
console.error('生成ER图时出错:', error); |
|
alert('生成ER图时出错: ' + error.message); |
|
} |
|
} |
|
|
|
|
|
document.addEventListener('DOMContentLoaded', function() { |
|
|
|
generateERDiagram(); |
|
|
|
|
|
document.getElementById('generateBtn').addEventListener('click', generateERDiagram); |
|
|
|
|
|
document.getElementById('diagramTitle').addEventListener('change', generateERDiagram); |
|
}); |
|
</script> |
|
</body> |
|
</html> |