| <!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> |