| <!DOCTYPE html> |
| <html lang="ko"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>디지털 등고선 지형 지도</title> |
| <style> |
| * { |
| margin: 0; |
| padding: 0; |
| box-sizing: border-box; |
| } |
| |
| body { |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| min-height: 100vh; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| padding: 20px; |
| } |
| |
| .container { |
| background: rgba(255, 255, 255, 0.95); |
| border-radius: 20px; |
| padding: 30px; |
| box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); |
| max-width: 1400px; |
| width: 100%; |
| } |
| |
| h1 { |
| text-align: center; |
| color: #333; |
| margin-bottom: 10px; |
| font-size: 2em; |
| background: linear-gradient(135deg, #667eea, #764ba2); |
| -webkit-background-clip: text; |
| -webkit-text-fill-color: transparent; |
| } |
| |
| .map-container { |
| position: relative; |
| margin: 20px auto; |
| border-radius: 10px; |
| overflow: hidden; |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); |
| background: #f0f0f0; |
| } |
| |
| canvas { |
| display: block; |
| cursor: crosshair; |
| width: 100%; |
| height: auto; |
| } |
| |
| .controls { |
| display: flex; |
| justify-content: center; |
| gap: 15px; |
| margin: 20px 0; |
| flex-wrap: wrap; |
| align-items: center; |
| } |
| |
| button { |
| padding: 12px 24px; |
| background: linear-gradient(135deg, #667eea, #764ba2); |
| color: white; |
| border: none; |
| border-radius: 25px; |
| cursor: pointer; |
| font-size: 14px; |
| font-weight: 600; |
| transition: all 0.3s ease; |
| box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); |
| } |
| |
| button:hover { |
| transform: translateY(-2px); |
| box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6); |
| } |
| |
| button:active { |
| transform: translateY(0); |
| } |
| |
| .zoom-controls { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| background: white; |
| padding: 8px 15px; |
| border-radius: 25px; |
| box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); |
| } |
| |
| .zoom-btn { |
| width: 35px; |
| height: 35px; |
| padding: 0; |
| border-radius: 50%; |
| font-size: 20px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| } |
| |
| .scale-info { |
| font-size: 14px; |
| color: #666; |
| font-weight: 600; |
| min-width: 100px; |
| text-align: center; |
| } |
| |
| .info-panel { |
| background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); |
| padding: 15px; |
| border-radius: 10px; |
| margin-top: 20px; |
| display: flex; |
| justify-content: space-around; |
| flex-wrap: wrap; |
| gap: 15px; |
| } |
| |
| .info-item { |
| text-align: center; |
| padding: 10px; |
| background: white; |
| border-radius: 8px; |
| min-width: 120px; |
| box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); |
| } |
| |
| .info-label { |
| font-size: 12px; |
| color: #666; |
| margin-bottom: 5px; |
| text-transform: uppercase; |
| letter-spacing: 1px; |
| } |
| |
| .info-value { |
| font-size: 18px; |
| font-weight: bold; |
| color: #333; |
| } |
| |
| .legend { |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 15px; |
| margin-top: 20px; |
| padding: 15px; |
| background: #f8f9fa; |
| border-radius: 10px; |
| flex-wrap: wrap; |
| } |
| |
| .legend-item { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| font-size: 14px; |
| } |
| |
| .legend-color { |
| width: 30px; |
| height: 20px; |
| border-radius: 4px; |
| border: 1px solid #ddd; |
| } |
| |
| .legend-symbol { |
| font-size: 20px; |
| margin-right: 5px; |
| } |
| |
| .tooltip { |
| position: absolute; |
| background: rgba(0, 0, 0, 0.9); |
| color: white; |
| padding: 8px 12px; |
| border-radius: 6px; |
| font-size: 14px; |
| pointer-events: none; |
| z-index: 1000; |
| display: none; |
| transition: all 0.2s ease; |
| } |
| |
| select { |
| padding: 10px 15px; |
| border-radius: 8px; |
| border: 2px solid #667eea; |
| background: white; |
| font-size: 14px; |
| cursor: pointer; |
| transition: all 0.3s ease; |
| } |
| |
| select:hover { |
| border-color: #764ba2; |
| } |
| |
| select:focus { |
| outline: none; |
| box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2); |
| } |
| |
| .map-size-info { |
| text-align: center; |
| color: #666; |
| font-size: 14px; |
| margin-top: 10px; |
| } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <h1>🗺️ 디지털 등고선 지형 지도</h1> |
| |
| <div class="controls"> |
| <button onclick="generateNewTerrain()">🏔️ 새로운 지형 생성</button> |
| <button onclick="toggleContours()">📊 등고선 표시/숨기기</button> |
| <button onclick="toggleHeatmap()">🌡️ 히트맵 전환</button> |
| <button onclick="toggleRoads()">🛣️ 도로 표시/숨기기</button> |
| <button onclick="toggleRivers()">💧 하천 표시/숨기기</button> |
| <select id="terrainType" onchange="changeTerrainType()"> |
| <option value="mountain">⛰️ 산악 지형</option> |
| <option value="valley">🏞️ 계곡 지형</option> |
| <option value="plateau">🏔️ 고원 지형</option> |
| <option value="rural">🏘️ 전원 지형</option> |
| <option value="urban">🏙️ 도시 평원</option> |
| </select> |
| <div class="zoom-controls"> |
| <button class="zoom-btn" onclick="zoomOut()">−</button> |
| <span class="scale-info" id="scaleInfo">1:5000</span> |
| <button class="zoom-btn" onclick="zoomIn()">+</button> |
| </div> |
| <button onclick="exportMap()">💾 지도 저장</button> |
| </div> |
|
|
| <div class="map-container"> |
| <canvas id="mapCanvas"></canvas> |
| <div class="tooltip" id="tooltip"></div> |
| </div> |
|
|
| <div class="map-size-info" id="mapSizeInfo"> |
| 지도 범위: 4km × 3km |
| </div> |
|
|
| <div class="info-panel"> |
| <div class="info-item"> |
| <div class="info-label">좌표</div> |
| <div class="info-value" id="coordinates">-</div> |
| </div> |
| <div class="info-item"> |
| <div class="info-label">고도</div> |
| <div class="info-value" id="elevation">-</div> |
| </div> |
| <div class="info-item"> |
| <div class="info-label">경사도</div> |
| <div class="info-value" id="slope">-</div> |
| </div> |
| <div class="info-item"> |
| <div class="info-label">실제 거리</div> |
| <div class="info-value" id="realDistance">-</div> |
| </div> |
| </div> |
|
|
| <div class="legend"> |
| <div class="legend-item"> |
| <div class="legend-color" style="background: #0d4f8b;"></div> |
| <span>수역 (0-50m)</span> |
| </div> |
| <div class="legend-item"> |
| <div class="legend-color" style="background: #61a861;"></div> |
| <span>평지 (50-200m)</span> |
| </div> |
| <div class="legend-item"> |
| <div class="legend-color" style="background: #f5deb3;"></div> |
| <span>구릉 (200-500m)</span> |
| </div> |
| <div class="legend-item"> |
| <div class="legend-color" style="background: #8b4513;"></div> |
| <span>산지 (500m+)</span> |
| </div> |
| <div class="legend-item"> |
| <span class="legend-symbol">━</span> |
| <span>도로</span> |
| </div> |
| <div class="legend-item"> |
| <span class="legend-symbol" style="color: #4682B4;">〰️</span> |
| <span>하천</span> |
| </div> |
| <div class="legend-item"> |
| <span class="legend-symbol">🏘️</span> |
| <span>마을</span> |
| </div> |
| <div class="legend-item"> |
| <span class="legend-symbol">🏢</span> |
| <span>도시</span> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| const canvas = document.getElementById('mapCanvas'); |
| const ctx = canvas.getContext('2d'); |
| const tooltip = document.getElementById('tooltip'); |
| |
| |
| const WIDTH = 800; |
| const HEIGHT = 600; |
| canvas.width = WIDTH; |
| canvas.height = HEIGHT; |
| |
| |
| const scales = [2500, 5000, 10000, 25000, 50000]; |
| let currentScaleIndex = 1; |
| let currentScale = scales[currentScaleIndex]; |
| let zoomLevel = 1; |
| let offsetX = 0; |
| let offsetY = 0; |
| |
| |
| let heightMap = []; |
| let roads = []; |
| let rivers = []; |
| let settlements = []; |
| let showContours = true; |
| let showHeatmap = false; |
| let showRoads = true; |
| let showRivers = true; |
| let terrainType = 'mountain'; |
| let seed = Math.random() * 10000; |
| let isDragging = false; |
| let dragStartX = 0; |
| let dragStartY = 0; |
| |
| |
| function noise(x, y, scale, octaves, seed) { |
| let value = 0; |
| let amplitude = 1; |
| let frequency = scale; |
| let maxValue = 0; |
| |
| for (let i = 0; i < octaves; i++) { |
| value += amplitude * simpleNoise(x * frequency + seed, y * frequency + seed); |
| maxValue += amplitude; |
| amplitude *= 0.5; |
| frequency *= 2; |
| } |
| |
| return value / maxValue; |
| } |
| |
| function simpleNoise(x, y) { |
| const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453; |
| return (n - Math.floor(n)) * 2 - 1; |
| } |
| |
| |
| function generateTerrain() { |
| seed = Math.random() * 10000; |
| heightMap = []; |
| roads = []; |
| rivers = []; |
| settlements = []; |
| |
| const gridSize = 100; |
| |
| |
| for (let y = 0; y < gridSize; y++) { |
| heightMap[y] = []; |
| for (let x = 0; x < gridSize; x++) { |
| let height = 0; |
| |
| switch(terrainType) { |
| case 'mountain': |
| height = noise(x, y, 0.02, 6, seed) * 0.6; |
| height += noise(x, y, 0.05, 4, seed + 100) * 0.3; |
| height += noise(x, y, 0.1, 2, seed + 200) * 0.1; |
| height = Math.pow(Math.abs(height), 1.2) * Math.sign(height); |
| break; |
| |
| case 'valley': |
| const distX = (x - gridSize/2) / gridSize; |
| const distY = (y - gridSize/2) / gridSize; |
| const dist = Math.sqrt(distX*distX + distY*distY); |
| height = noise(x, y, 0.03, 4, seed) * 0.5; |
| height *= (1 - dist * 0.5); |
| height -= dist * 0.3; |
| break; |
| |
| case 'plateau': |
| height = noise(x, y, 0.02, 3, seed) * 0.3; |
| if (height > 0.1) height = 0.3 + noise(x, y, 0.05, 2, seed + 100) * 0.1; |
| break; |
| |
| case 'rural': |
| |
| height = noise(x, y, 0.015, 4, seed) * 0.2; |
| height += noise(x, y, 0.03, 2, seed + 100) * 0.1; |
| height = Math.max(0.1, height); |
| break; |
| |
| case 'urban': |
| |
| height = noise(x, y, 0.01, 2, seed) * 0.1 + 0.15; |
| break; |
| } |
| |
| |
| height = Math.max(0, Math.min(1, (height + 1) / 2)); |
| heightMap[y][x] = height; |
| } |
| } |
| |
| |
| generateRoads(gridSize); |
| |
| |
| generateRivers(gridSize); |
| |
| |
| generateSettlements(gridSize); |
| } |
| |
| |
| function generateRoads(gridSize) { |
| roads = []; |
| const numRoads = terrainType === 'urban' ? 8 : (terrainType === 'rural' ? 5 : 3); |
| |
| for (let i = 0; i < numRoads; i++) { |
| const road = []; |
| let x = Math.random() * gridSize; |
| let y = Math.random() * gridSize; |
| const targetX = Math.random() * gridSize; |
| const targetY = Math.random() * gridSize; |
| |
| |
| for (let step = 0; step < 50; step++) { |
| road.push({ x: Math.floor(x), y: Math.floor(y) }); |
| |
| |
| const dx = targetX - x; |
| const dy = targetY - y; |
| const angle = Math.atan2(dy, dx); |
| |
| |
| let bestAngle = angle; |
| let minSlope = Infinity; |
| |
| for (let a = -Math.PI/4; a <= Math.PI/4; a += Math.PI/8) { |
| const testAngle = angle + a; |
| const nx = x + Math.cos(testAngle) * 2; |
| const ny = y + Math.sin(testAngle) * 2; |
| |
| if (nx >= 0 && nx < gridSize && ny >= 0 && ny < gridSize) { |
| const slope = Math.abs(heightMap[Math.floor(ny)][Math.floor(nx)] - |
| heightMap[Math.floor(y)][Math.floor(x)]); |
| if (slope < minSlope) { |
| minSlope = slope; |
| bestAngle = testAngle; |
| } |
| } |
| } |
| |
| x += Math.cos(bestAngle) * 2; |
| y += Math.sin(bestAngle) * 2; |
| |
| if (Math.abs(x - targetX) < 2 && Math.abs(y - targetY) < 2) break; |
| } |
| |
| roads.push(road); |
| } |
| } |
| |
| |
| function generateRivers(gridSize) { |
| rivers = []; |
| const numRivers = terrainType === 'valley' ? 3 : 2; |
| |
| for (let i = 0; i < numRivers; i++) { |
| const river = []; |
| |
| |
| let x = Math.random() * gridSize; |
| let y = Math.random() * gridSize; |
| let highestPoint = heightMap[Math.floor(y)][Math.floor(x)]; |
| |
| |
| for (let j = 0; j < 10; j++) { |
| const tx = Math.random() * gridSize; |
| const ty = Math.random() * gridSize; |
| if (heightMap[Math.floor(ty)][Math.floor(tx)] > highestPoint) { |
| x = tx; |
| y = ty; |
| highestPoint = heightMap[Math.floor(ty)][Math.floor(tx)]; |
| } |
| } |
| |
| |
| for (let step = 0; step < 100; step++) { |
| river.push({ x: Math.floor(x), y: Math.floor(y) }); |
| |
| |
| let lowestHeight = heightMap[Math.floor(y)][Math.floor(x)]; |
| let nextX = x, nextY = y; |
| |
| for (let dx = -1; dx <= 1; dx++) { |
| for (let dy = -1; dy <= 1; dy++) { |
| if (dx === 0 && dy === 0) continue; |
| |
| const nx = Math.floor(x + dx); |
| const ny = Math.floor(y + dy); |
| |
| if (nx >= 0 && nx < gridSize && ny >= 0 && ny < gridSize) { |
| if (heightMap[ny][nx] < lowestHeight) { |
| lowestHeight = heightMap[ny][nx]; |
| nextX = x + dx + (Math.random() - 0.5) * 0.5; |
| nextY = y + dy + (Math.random() - 0.5) * 0.5; |
| } |
| } |
| } |
| } |
| |
| if (nextX === x && nextY === y) break; |
| |
| x = nextX; |
| y = nextY; |
| |
| if (lowestHeight < 0.2) break; |
| } |
| |
| rivers.push(river); |
| } |
| } |
| |
| |
| function generateSettlements(gridSize) { |
| settlements = []; |
| |
| if (terrainType === 'rural') { |
| |
| const numVillages = 5 + Math.floor(Math.random() * 5); |
| for (let i = 0; i < numVillages; i++) { |
| let x, y; |
| do { |
| x = Math.random() * gridSize; |
| y = Math.random() * gridSize; |
| } while (heightMap[Math.floor(y)][Math.floor(x)] > 0.5 || |
| heightMap[Math.floor(y)][Math.floor(x)] < 0.15); |
| |
| settlements.push({ |
| x: Math.floor(x), |
| y: Math.floor(y), |
| type: 'village', |
| size: 3 + Math.random() * 3 |
| }); |
| } |
| } else if (terrainType === 'urban') { |
| |
| const numCities = 2 + Math.floor(Math.random() * 2); |
| for (let i = 0; i < numCities; i++) { |
| let x = (i + 1) * gridSize / (numCities + 1) + (Math.random() - 0.5) * 20; |
| let y = gridSize / 2 + (Math.random() - 0.5) * 30; |
| |
| settlements.push({ |
| x: Math.floor(x), |
| y: Math.floor(y), |
| type: 'city', |
| size: 10 + Math.random() * 10 |
| }); |
| } |
| |
| |
| const numTowns = 5 + Math.floor(Math.random() * 5); |
| for (let i = 0; i < numTowns; i++) { |
| let x = Math.random() * gridSize; |
| let y = Math.random() * gridSize; |
| |
| settlements.push({ |
| x: Math.floor(x), |
| y: Math.floor(y), |
| type: 'town', |
| size: 5 + Math.random() * 5 |
| }); |
| } |
| } |
| } |
| |
| |
| function drawMap() { |
| ctx.clearRect(0, 0, WIDTH, HEIGHT); |
| |
| |
| ctx.save(); |
| ctx.translate(WIDTH/2, HEIGHT/2); |
| ctx.scale(zoomLevel, zoomLevel); |
| ctx.translate(-WIDTH/2 + offsetX, -HEIGHT/2 + offsetY); |
| |
| const gridSize = heightMap.length; |
| const cellWidth = WIDTH / gridSize; |
| const cellHeight = HEIGHT / gridSize; |
| |
| |
| for (let y = 0; y < gridSize; y++) { |
| for (let x = 0; x < gridSize; x++) { |
| const height = heightMap[y][x]; |
| |
| if (showHeatmap) { |
| const hue = (1 - height) * 240; |
| ctx.fillStyle = `hsl(${hue}, 70%, 50%)`; |
| } else { |
| ctx.fillStyle = getTerrainColor(height); |
| } |
| |
| ctx.fillRect( |
| x * cellWidth, |
| y * cellHeight, |
| cellWidth + 1, |
| cellHeight + 1 |
| ); |
| } |
| } |
| |
| |
| if (showRivers) { |
| rivers.forEach(river => { |
| ctx.strokeStyle = '#4682B4'; |
| ctx.lineWidth = 2; |
| ctx.beginPath(); |
| |
| river.forEach((point, index) => { |
| const x = point.x * cellWidth; |
| const y = point.y * cellHeight; |
| |
| if (index === 0) { |
| ctx.moveTo(x, y); |
| } else { |
| ctx.lineTo(x, y); |
| } |
| }); |
| |
| ctx.stroke(); |
| }); |
| } |
| |
| |
| if (showRoads) { |
| roads.forEach(road => { |
| ctx.strokeStyle = '#555'; |
| ctx.lineWidth = 2; |
| ctx.setLineDash([5, 3]); |
| ctx.beginPath(); |
| |
| road.forEach((point, index) => { |
| const x = point.x * cellWidth; |
| const y = point.y * cellHeight; |
| |
| if (index === 0) { |
| ctx.moveTo(x, y); |
| } else { |
| ctx.lineTo(x, y); |
| } |
| }); |
| |
| ctx.stroke(); |
| ctx.setLineDash([]); |
| }); |
| } |
| |
| |
| if (showContours) { |
| ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)'; |
| ctx.lineWidth = 1; |
| |
| const contourLevels = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]; |
| |
| for (let level of contourLevels) { |
| ctx.beginPath(); |
| |
| for (let y = 0; y < gridSize - 1; y++) { |
| for (let x = 0; x < gridSize - 1; x++) { |
| const corners = [ |
| heightMap[y][x], |
| heightMap[y][x + 1], |
| heightMap[y + 1][x + 1], |
| heightMap[y + 1][x] |
| ]; |
| |
| drawContourCell( |
| x * cellWidth, |
| y * cellHeight, |
| cellWidth, |
| cellHeight, |
| corners, |
| level |
| ); |
| } |
| } |
| |
| if (level % 0.2 === 0) { |
| ctx.lineWidth = 2; |
| ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)'; |
| } else { |
| ctx.lineWidth = 1; |
| ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)'; |
| } |
| |
| ctx.stroke(); |
| } |
| } |
| |
| |
| settlements.forEach(settlement => { |
| const x = settlement.x * cellWidth; |
| const y = settlement.y * cellHeight; |
| |
| if (settlement.type === 'city') { |
| |
| ctx.fillStyle = '#666'; |
| ctx.fillRect(x - settlement.size/2, y - settlement.size/2, settlement.size, settlement.size); |
| ctx.strokeStyle = '#333'; |
| ctx.strokeRect(x - settlement.size/2, y - settlement.size/2, settlement.size, settlement.size); |
| |
| ctx.font = '20px Arial'; |
| ctx.fillText('🏢', x - 10, y + 5); |
| } else if (settlement.type === 'village') { |
| |
| ctx.fillStyle = '#8B7355'; |
| ctx.beginPath(); |
| ctx.arc(x, y, settlement.size, 0, Math.PI * 2); |
| ctx.fill(); |
| |
| ctx.font = '16px Arial'; |
| ctx.fillText('🏘️', x - 8, y + 5); |
| } else { |
| |
| ctx.fillStyle = '#999'; |
| ctx.fillRect(x - settlement.size/2, y - settlement.size/2, settlement.size, settlement.size); |
| |
| ctx.font = '14px Arial'; |
| ctx.fillText('🏪', x - 7, y + 5); |
| } |
| }); |
| |
| |
| ctx.restore(); |
| drawScaleBar(); |
| } |
| |
| |
| function drawContourCell(x, y, width, height, corners, level) { |
| let state = 0; |
| if (corners[0] > level) state |= 1; |
| if (corners[1] > level) state |= 2; |
| if (corners[2] > level) state |= 4; |
| if (corners[3] > level) state |= 8; |
| |
| switch(state) { |
| case 1: case 14: |
| drawLine(x, y + height * interpolate(corners[0], corners[3], level), |
| x + width * interpolate(corners[0], corners[1], level), y); |
| break; |
| case 2: case 13: |
| drawLine(x + width * interpolate(corners[0], corners[1], level), y, |
| x + width, y + height * interpolate(corners[1], corners[2], level)); |
| break; |
| case 3: case 12: |
| drawLine(x, y + height * interpolate(corners[0], corners[3], level), |
| x + width, y + height * interpolate(corners[1], corners[2], level)); |
| break; |
| case 4: case 11: |
| drawLine(x + width, y + height * interpolate(corners[1], corners[2], level), |
| x + width * interpolate(corners[3], corners[2], level), y + height); |
| break; |
| case 5: |
| drawLine(x, y + height * interpolate(corners[0], corners[3], level), |
| x + width * interpolate(corners[0], corners[1], level), y); |
| drawLine(x + width, y + height * interpolate(corners[1], corners[2], level), |
| x + width * interpolate(corners[3], corners[2], level), y + height); |
| break; |
| case 6: case 9: |
| drawLine(x + width * interpolate(corners[0], corners[1], level), y, |
| x + width * interpolate(corners[3], corners[2], level), y + height); |
| break; |
| case 7: case 8: |
| drawLine(x, y + height * interpolate(corners[0], corners[3], level), |
| x + width * interpolate(corners[3], corners[2], level), y + height); |
| break; |
| case 10: |
| drawLine(x + width * interpolate(corners[0], corners[1], level), y, |
| x + width, y + height * interpolate(corners[1], corners[2], level)); |
| drawLine(x, y + height * interpolate(corners[0], corners[3], level), |
| x + width * interpolate(corners[3], corners[2], level), y + height); |
| break; |
| } |
| } |
| |
| function interpolate(v1, v2, level) { |
| return (level - v1) / (v2 - v1); |
| } |
| |
| function drawLine(x1, y1, x2, y2) { |
| ctx.moveTo(x1, y1); |
| ctx.lineTo(x2, y2); |
| } |
| |
| |
| function drawScaleBar() { |
| const barWidth = 100; |
| const barHeight = 10; |
| const x = WIDTH - barWidth - 20; |
| const y = HEIGHT - 30; |
| |
| |
| const realDistance = (barWidth * currentScale) / 1000; |
| |
| ctx.fillStyle = '#000'; |
| ctx.fillRect(x, y, barWidth, barHeight); |
| |
| ctx.fillStyle = '#fff'; |
| ctx.fillRect(x, y, barWidth/2, barHeight); |
| |
| ctx.fillStyle = '#000'; |
| ctx.font = '12px Arial'; |
| ctx.fillText(`0`, x - 5, y - 5); |
| ctx.fillText(`${realDistance.toFixed(1)}km`, x + barWidth - 20, y - 5); |
| } |
| |
| |
| function getTerrainColor(height) { |
| if (height < 0.15) return '#0d4f8b'; |
| if (height < 0.25) return '#1e7eb8'; |
| if (height < 0.35) return '#61a861'; |
| if (height < 0.45) return '#8fc68f'; |
| if (height < 0.55) return '#c4d4aa'; |
| if (height < 0.65) return '#f5deb3'; |
| if (height < 0.75) return '#d2b48c'; |
| if (height < 0.85) return '#8b4513'; |
| return '#fff'; |
| } |
| |
| |
| canvas.addEventListener('mousemove', (e) => { |
| const rect = canvas.getBoundingClientRect(); |
| |
| if (isDragging) { |
| const dx = e.clientX - dragStartX; |
| const dy = e.clientY - dragStartY; |
| offsetX = dx / zoomLevel; |
| offsetY = dy / zoomLevel; |
| drawMap(); |
| return; |
| } |
| |
| |
| const x = ((e.clientX - rect.left) * (WIDTH / rect.width) - WIDTH/2) / zoomLevel + WIDTH/2 - offsetX; |
| const y = ((e.clientY - rect.top) * (HEIGHT / rect.height) - HEIGHT/2) / zoomLevel + HEIGHT/2 - offsetY; |
| |
| const gridSize = heightMap.length; |
| const gridX = Math.floor(x / (WIDTH / gridSize)); |
| const gridY = Math.floor(y / (HEIGHT / gridSize)); |
| |
| if (gridX >= 0 && gridX < gridSize && gridY >= 0 && gridY < gridSize) { |
| const height = heightMap[gridY][gridX]; |
| const elevation = Math.round(height * 1000); |
| |
| |
| let slope = 0; |
| if (gridX > 0 && gridX < gridSize - 1 && gridY > 0 && gridY < gridSize - 1) { |
| const dx = heightMap[gridY][gridX + 1] - heightMap[gridY][gridX - 1]; |
| const dy = heightMap[gridY + 1][gridX] - heightMap[gridY - 1][gridX]; |
| slope = Math.round(Math.sqrt(dx * dx + dy * dy) * 100); |
| } |
| |
| |
| const mapWidthKm = (WIDTH * currentScale / zoomLevel) / 1000000; |
| const mapHeightKm = (HEIGHT * currentScale / zoomLevel) / 1000000; |
| const realX = (gridX * mapWidthKm / gridSize).toFixed(2); |
| const realY = (gridY * mapHeightKm / gridSize).toFixed(2); |
| |
| document.getElementById('coordinates').textContent = `${gridX}, ${gridY}`; |
| document.getElementById('elevation').textContent = `${elevation}m`; |
| document.getElementById('slope').textContent = `${slope}°`; |
| document.getElementById('realDistance').textContent = `${realX}, ${realY}km`; |
| |
| tooltip.style.display = 'block'; |
| tooltip.style.left = e.clientX + 10 + 'px'; |
| tooltip.style.top = e.clientY - 30 + 'px'; |
| tooltip.textContent = `고도: ${elevation}m`; |
| } |
| }); |
| |
| |
| canvas.addEventListener('mousedown', (e) => { |
| if (e.button === 0) { |
| isDragging = true; |
| dragStartX = e.clientX - offsetX * zoomLevel; |
| dragStartY = e.clientY - offsetY * zoomLevel; |
| canvas.style.cursor = 'grabbing'; |
| } |
| }); |
| |
| canvas.addEventListener('mouseup', () => { |
| isDragging = false; |
| canvas.style.cursor = 'crosshair'; |
| }); |
| |
| canvas.addEventListener('mouseleave', () => { |
| isDragging = false; |
| canvas.style.cursor = 'crosshair'; |
| tooltip.style.display = 'none'; |
| document.getElementById('coordinates').textContent = '-'; |
| document.getElementById('elevation').textContent = '-'; |
| document.getElementById('slope').textContent = '-'; |
| document.getElementById('realDistance').textContent = '-'; |
| }); |
| |
| |
| canvas.addEventListener('wheel', (e) => { |
| e.preventDefault(); |
| const delta = e.deltaY > 0 ? 0.9 : 1.1; |
| const newZoom = zoomLevel * delta; |
| |
| if (newZoom >= 0.5 && newZoom <= 4) { |
| zoomLevel = newZoom; |
| updateScaleInfo(); |
| drawMap(); |
| } |
| }); |
| |
| |
| function generateNewTerrain() { |
| generateTerrain(); |
| drawMap(); |
| } |
| |
| function toggleContours() { |
| showContours = !showContours; |
| drawMap(); |
| } |
| |
| function toggleHeatmap() { |
| showHeatmap = !showHeatmap; |
| drawMap(); |
| } |
| |
| function toggleRoads() { |
| showRoads = !showRoads; |
| drawMap(); |
| } |
| |
| function toggleRivers() { |
| showRivers = !showRivers; |
| drawMap(); |
| } |
| |
| function changeTerrainType() { |
| terrainType = document.getElementById('terrainType').value; |
| generateTerrain(); |
| drawMap(); |
| } |
| |
| function zoomIn() { |
| if (currentScaleIndex > 0) { |
| currentScaleIndex--; |
| currentScale = scales[currentScaleIndex]; |
| zoomLevel = Math.min(4, zoomLevel * 1.5); |
| offsetX = 0; |
| offsetY = 0; |
| updateScaleInfo(); |
| drawMap(); |
| } |
| } |
| |
| function zoomOut() { |
| if (currentScaleIndex < scales.length - 1) { |
| currentScaleIndex++; |
| currentScale = scales[currentScaleIndex]; |
| zoomLevel = Math.max(0.5, zoomLevel / 1.5); |
| offsetX = 0; |
| offsetY = 0; |
| updateScaleInfo(); |
| drawMap(); |
| } |
| } |
| |
| function updateScaleInfo() { |
| const effectiveScale = currentScale / zoomLevel; |
| document.getElementById('scaleInfo').textContent = `1:${Math.round(effectiveScale)}`; |
| |
| |
| const widthKm = (WIDTH * effectiveScale / 1000000).toFixed(1); |
| const heightKm = (HEIGHT * effectiveScale / 1000000).toFixed(1); |
| |
| |
| const actualWidthKm = Math.min(20, widthKm); |
| const actualHeightKm = Math.min(15, heightKm); |
| |
| document.getElementById('mapSizeInfo').textContent = |
| `지도 범위: ${actualWidthKm}km × ${actualHeightKm}km (줌: ${(zoomLevel * 100).toFixed(0)}%)`; |
| } |
| |
| function exportMap() { |
| const link = document.createElement('a'); |
| link.download = `topographic-map-${terrainType}-${Date.now()}.png`; |
| link.href = canvas.toDataURL(); |
| link.click(); |
| } |
| |
| |
| generateTerrain(); |
| drawMap(); |
| updateScaleInfo(); |
| |
| |
| window.addEventListener('resize', () => { |
| drawMap(); |
| }); |
| </script> |
| </body> |
| </html> |