
前段时间看到一个很有意思的展示型项目输入一个地址就能看到某年 8 月 12 日那次日食在用户头顶会呈现出怎样的效果。地址不同看到的日食阶段和最大遮挡率也不同有的地方是日全食有的地方只是日偏食甚至有的地方完全看不到。这类工具把天文学计算、地理编码、前端可视化放在了一起很适合作为一次完整的 Web 开发练手项目。本文会带大家从零实现一个类似的“地址 → 日食模拟”小应用。我们会先理清日食的基础概念再介绍整体技术架构然后分别完成后端 API、前端页面和 Canvas 日食绘制最后给出常见问题和工程建议。代码尽量保持简单不依赖重型框架方便你直接复制运行并继续扩展。1. 背景与核心概念1.1 日食是什么为什么不同地址看到的日食不一样日食是由于月球运行到太阳和地球之间月球的影子投射到地球表面使得地球上某些区域的观察者看到太阳被月球遮挡的天文现象。根据遮挡程度日食通常分为三种日全食月球完全遮住太阳可以看到日冕。日环食月球圆面无法完全遮住太阳太阳边缘露出一圈光环。日偏食月球只遮住太阳的一部分。月球影子在地球表面会形成一条狭窄的“全食带”或“环食带”。只有位于这条影子带内的地区才能看到日全食或日环食影子带以外的区域根据距离影带的远近只能看到不同程度的日偏食甚至完全看不到。这正是“从你的地址看日食”产生差异的原因。设计这类工具时核心任务就是把用户输入的地址转换为经纬度再根据该经纬度与日食中心线的相对位置估算当地能看到的最大食分和日食类型。1.2 这类工具的应用场景与价值看起来这只是一个“天文小玩具”但背后的逻辑其实可以复用到很多场景天文科普网站根据用户位置展示当地可见的日食、月食信息。活动策划为日食观测活动选择合适的城市和观测点。摄影辅助帮助摄影师提前了解某地日食的发生时间和最大遮挡程度。地理信息可视化演示点选地图、坐标计算、Canvas 绘制的完整链路。从开发角度看这个项目的技术点非常有代表性前端表单交互、后端 HTTP API、地理编码服务、时区处理、基础几何计算和图形绘制覆盖了一个真实 Web 应用的常见环节。2. 环境准备与整体设计2.1 技术栈选择为了让项目尽量轻量适合新手和进阶开发者阅读我选择以下技术栈后端Python 3 Flask。前端原生 HTML / CSS / JavaScript不引入 Vue、React 等框架。地理编码使用 OpenStreetMap 的 Nominatim 公共服务免费且无需 API Key。时区处理使用timezonefinder和 Python 标准库zoneinfo。日食数据本文采用一个本地 JSON 文件保存示例日食事件而不是实时天文计算。这样做的好处是逻辑清晰、可复现并且不依赖复杂天文算法。版本说明本文示例基于 Python 3.8 和 Flask 2.x如果你使用 Flask 3.x主要 API 基本保持一致。timezonefinder在部分系统上需要依赖 C 编译器如果安装失败可以直接使用一个简化的时区映射表不影响整体功能。2.2 整体功能流程我们可以把整个流程拆成以下步骤用户在页面输入地址例如“北京市朝阳区”或“New York, NY”。前端将地址通过 HTTP 请求发送到后端/api/eclipse接口。后端调用 Nominatim 地理编码服务将地址解析为经纬度。后端读取本地日食事件数据计算用户位置与中心线的距离估算最大食分和日食类型。后端根据经纬度获取时区计算出当地日食最大时刻。前端拿到 JSON 结果后展示文字信息并在 Canvas 上绘制日食模拟图。用一张简单的 ASCII 图表示用户输入地址 │ ▼ 后端 /api/eclipse │ ├──► 地理编码地址 → 经纬度 │ ├──► 日食数据文件中心线、日期、时间 │ ├──► 距离估算 → 最大食分 / 日食类型 │ └──► 时区转换 → 当地时间 │ ▼ 返回 JSON 给前端 │ ▼ 前端展示文字 Canvas 绘制日食效果3. 核心模块拆解3.1 地址解析Geocoding 的基本思路地理编码分为两种正向地理编码把地址文字转换成经纬度坐标。反向地理编码把经纬度坐标转换成地址文字。我们的应用需要的是正向地理编码。Nominatim 是 OpenStreetMap 提供的免费公共服务请求地址示例为https://nominatim.openstreetmap.org/search?qBeijingformatjsonlimit1返回结果是一个 JSON 数组每个元素包含lat、lon、display_name等字段。需要注意的是Nominatim 公共接口要求请求方带上合理的 User-Agent并且请求频率不能过高一般推荐每秒不超过 1 次。在生产环境可以替换为高德、百度或 Google Maps 的 Geocoding API但需要注意每个服务的授权方式和费用以及国内地图服务的合规要求。3.2 日食遮挡的视觉模拟原理日食模拟图的核心是绘制两个圆一个表示太阳一个表示月亮。太阳圆通常填充为黄色或橙黄色。月亮圆填充为深灰色或黑色。月亮圆的位置根据“食分”发生偏移。在真实天文场景中太阳和月亮的视半径会随着地球与月球距离变化而变化精确计算很复杂。但作为可视化演示我们可以采用一个简化模型假设月亮圆半径略大于太阳圆半径月亮圆心与太阳圆心的偏移量offset与最大食分magnitude线性相关。例如当magnitude 0时代表没有遮挡月亮圆心应该在太阳圆之外。当magnitude 1时代表日全食月亮圆心与太阳圆心重合。当magnitude 0.5时月亮圆心介于两者之间。这种模型虽然不够天文学精确但能非常直观地表现“日食在某个地点看起来是什么样子”适合教学演示。3.3 从中心线距离估算食分日食中心线是全食带或环食带的中间线。距离中心线越近遮挡程度越高距离越远遮挡程度越低。我们可以把中心线看作一串经纬度点组成的折线。对于用户位置我们计算它到折线上最近线段的距离。然后根据一个预设的“带状半宽”参数估算食分距离为 0 时食分最大假设为 1.0。距离超过某个阈值时食分为 0表示不可见。中间部分使用线性或二次插值。这个模型只是为了演示真实日食计算需要精确的月球本影和半影参数。文章后面会强调这一点避免误导读者。4. 完整实战案例搭建地址到日食模拟应用接下来进入代码实战。我们将创建一个名为eclipse-viewer的项目包含后端 Flask 服务和前端页面。4.1 创建项目结构在终端中执行以下命令创建项目目录mkdir eclipse-viewer cd eclipse-viewer mkdir templates static data项目文件结构如下eclipse-viewer/ ├── app.py ├── templates/ │ └── index.html ├── static/ │ ├── style.css │ └── app.js └── data/ └── eclipse_event.json4.2 准备示例日食事件数据在data/eclipse_event.json中保存日食事件的基本信息。这里使用占位数据演示完整流程不代表真实天文预测。{ event_name: 示例日食事件请替换为真实事件, event_date: 2026-08-12, event_time_utc: 17:30:00, max_magnitude: 1.0, totality_half_width_km: 100, visible_threshold_km: 800, centerline: [ {lat: 38.0, lon: -120.0}, {lat: 40.0, lon: -115.0}, {lat: 42.0, lon: -108.0}, {lat: 44.0, lon: -100.0}, {lat: 46.0, lon: -90.0} ] }字段说明event_name日食事件的名称。event_date日食发生的日期UTC。event_time_utc日食最大时刻UTC。max_magnitude中心线上理论最大食分这里设为 1.0表示日全食。totality_half_width_km全食带半宽单位公里。visible_threshold_km超过该距离则认为不可见。centerline中心线经纬度点数组。注意这个 JSON 只是演示数据。如果你有真实的日食事件数据可以直接替换。4.3 编写后端 Flask 服务后端文件是app.py核心实现如下。import json import math from datetime import datetime, timedelta from zoneinfo import ZoneInfo import requests from flask import Flask, jsonify, render_template, request app Flask(__name__) NOMINATIM_URL https://nominatim.openstreetmap.org/search def load_eclipse_event(): with open(data/eclipse_event.json, r, encodingutf-8) as f: return json.load(f) def geocode_address(address): 将地址解析为经纬度返回 None 表示解析失败。 params { q: address, format: json, limit: 1, } headers { User-Agent: eclipse-viewer-demo/1.0 (educational project) } resp requests.get(NOMINATIM_URL, paramsparams, headersheaders, timeout10) resp.raise_for_status() data resp.json() if not data: return None return float(data[0][lat]), float(data[0][lon]) def haversine_km(lat1, lon1, lat2, lon2): 计算两个经纬度点之间的距离单位公里。 R 6371.0 phi1 math.radians(lat1) phi2 math.radians(lat2) dphi math.radians(lat2 - lat1) dlambda math.radians(lon2 - lon1) a math.sin(dphi / 2) ** 2 math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2 c 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) return R * c def point_to_line_distance_km(lat, lon, lat1, lon1, lat2, lon2): 将经纬度近似看作平面坐标计算点到线段的距离。 注意这是一个简化实现适用于小范围场景。中心线跨度大时会有误差 但足以演示整体思路。 x lon y lat x1 lon1 y1 lat1 x2 lon2 y2 lat2 dx x2 - x1 dy y2 - y1 if dx 0 and dy 0: return haversine_km(lat, lon, lat1, lon1) t ((x - x1) * dx (y - y1) * dy) / (dx * dx dy * dy) t max(0, min(1, t)) proj_lat y1 t * dy proj_lon x1 t * dx return haversine_km(lat, lon, proj_lat, proj_lon) def estimate_magnitude(lat, lon, event): 估算用户位置的最大食分。 centerline event[centerline] max_magnitude event[max_magnitude] visible_threshold_km event[visible_threshold_km] min_dist float(inf) for i in range(len(centerline) - 1): dist point_to_line_distance_km( lat, lon, centerline[i][lat], centerline[i][lon], centerline[i 1][lat], centerline[i 1][lon] ) if dist min_dist: min_dist dist if min_dist visible_threshold_km: return 0.0 # 这里用线性映射计算食分距离为 0 时为 max_magnitude距离为阈值时为 0 magnitude max_magnitude * (1 - min_dist / visible_threshold_km) return round(max(0.0, min(max_magnitude, magnitude)), 3) def get_timezone(lat, lon): 使用 timezonefinder 获取时区名如果不可用则默认 UTC。 try: from timezonefinder import TimezoneFinder tf TimezoneFinder() tz_name tf.timezone_at(latlat, lnglon) if tz_name: return tz_name except Exception: pass return UTC def local_max_time(event, tz_name): 将 UTC 最大时刻转换为当地时区时间。 utc_dt datetime.strptime( f{event[event_date]} {event[event_time_utc]}, %Y-%m-%d %H:%M:%S ) utc_dt utc_dt.replace(tzinfoZoneInfo(UTC)) local_dt utc_dt.astimezone(ZoneInfo(tz_name)) return local_dt.strftime(%Y-%m-%d %H:%M:%S) def phase_by_magnitude(magnitude): 根据食分返回日食类型。 if magnitude 0: return none if magnitude 0.999: return total if magnitude 0.9: return near-total return partial app.route(/) def index(): return render_template(index.html) app.route(/api/eclipse) def api_eclipse(): address request.args.get(address, ).strip() if not address: return jsonify({error: address 不能为空}), 400 try: geo geocode_address(address) if geo is None: return jsonify({error: 无法解析该地址请换一个更完整的描述}), 404 lat, lon geo event load_eclipse_event() magnitude estimate_magnitude(lat, lon, event) tz_name get_timezone(lat, lon) local_time local_max_time(event, tz_name) phase phase_by_magnitude(magnitude) return jsonify({ address: address, lat: lat, lon: lon, timezone: tz_name, local_max_time: local_time, magnitude: magnitude, phase: phase }) except Exception as e: return jsonify({error: f服务暂时不可用{str(e)}}), 500 if __name__ __main__: app.run(debugTrue, host127.0.0.1, port5000)代码说明geocode_address调用 Nominatim解析地址。haversine_km计算两个经纬度点之间的大圆距离。point_to_line_distance_km计算点到折线的最短距离用于估算与中心线的距离。estimate_magnitude通过距离映射到食分。get_timezone根据经纬度获取时区需要安装timezonefinder。local_max_time把 UTC 时间转换为当地时间。4.4 编写前端页面前端页面放在templates/index.html包含表单和展示区域。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title地址日食模拟器/title link relstylesheet href/static/style.css /head body div classcontainer h1地址日食模拟器/h1 p输入一个地址查看日食在当地的最大呈现效果。/p form ideclipse-form input typetext idaddress-input placeholder例如北京市朝阳区 required button typesubmit查询/button /form div idresult classresult hidden h2查询结果/h2 p idaddress-text/p p idtime-text/p p idmagnitude-text/p p idphase-text/p canvas ideclipse-canvas width280 height280/canvas /div p iderror-message classerror hidden/p /div script src/static/app.js/script /body /html4.5 编写前端样式样式文件static/style.css保证页面清晰美观。* { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; background: linear-gradient(135deg, #0b1026 0%, #1b2a5b 100%); color: #f5f5f5; min-height: 100vh; display: flex; align-items: center; justify-content: center; } .container { background: rgba(255, 255, 255, 0.08); backdrop-filter: blur(8px); border-radius: 16px; padding: 40px; width: 520px; max-width: 90%; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4); } h1 { font-size: 24px; margin-bottom: 8px; } form { display: flex; gap: 10px; margin-top: 20px; } input { flex: 1; padding: 10px 14px; border: none; border-radius: 8px; font-size: 14px; } button { padding: 10px 20px; border: none; border-radius: 8px; background: #f5a524; color: #1a1a1a; font-weight: 600; cursor: pointer; } button:hover { background: #ffc04d; } .result { margin-top: 24px; } .result p { margin: 6px 0; } canvas { display: block; margin: 20px auto 0; border-radius: 50%; background: #000; } .error { margin-top: 16px; color: #ff6b6b; }4.6 编写前端交互逻辑前端逻辑放在static/app.js负责请求后端接口并绘制日食效果。const form document.getElementById(eclipse-form); const addressInput document.getElementById(address-input); const resultDiv document.getElementById(result); const errorMessage document.getElementById(error-message); const canvas document.getElementById(eclipse-canvas); function drawEclipse(magnitude, phase) { const ctx canvas.getContext(2d); const w canvas.width; const h canvas.height; const cx w / 2; const cy h / 2; ctx.clearRect(0, 0, w, h); // 背景色模拟天空 ctx.fillStyle #000; ctx.fillRect(0, 0, w, h); // 太阳半径 const sunRadius 80; const moonRadius sunRadius * 1.05; // 根据食分计算月亮圆心偏移 const maxOffset sunRadius moonRadius 10; const offset maxOffset * (1 - magnitude); const moonX cx offset; const moonY cy; // 绘制太阳 ctx.beginPath(); ctx.arc(cx, cy, sunRadius, 0, Math.PI * 2); ctx.fillStyle #ffd666; ctx.shadowColor #ffaa00; ctx.shadowBlur 30; ctx.fill(); ctx.shadowBlur 0; // 绘制月亮 ctx.beginPath(); ctx.arc(moonX, moonY, moonRadius, 0, Math.PI * 2); ctx.fillStyle #2b2b2b; ctx.fill(); // 如果是全食或接近全食绘制日冕效果 if (magnitude 0.95) { ctx.beginPath(); ctx.arc(cx, cy, sunRadius * 0.3, 0, Math.PI * 2); ctx.fillStyle #fff8e1; ctx.fill(); } } function displayResult(data) { resultDiv.hidden false; errorMessage.hidden true; document.getElementById(address-text).textContent 查询地址 data.address; document.getElementById(time-text).textContent 当地日食最大时刻 data.local_max_time; document.getElementById(magnitude-text).textContent 最大食分 data.magnitude; const phaseMap { total: 日全食, near-total: 接近全食, partial: 日偏食, none: 不可见 }; document.getElementById(phase-text).textContent 日食类型 (phaseMap[data.phase] || data.phase); drawEclipse(data.magnitude, data.phase); } function showError(message) { resultDiv.hidden true; errorMessage.hidden false; errorMessage.textContent message; } form.addEventListener(submit, async function (e) { e.preventDefault(); const address addressInput.value.trim(); if (!address) { return; } try { const resp await fetch(/api/eclipse?address encodeURIComponent(address)); const data await resp.json(); if (!resp.ok) { throw new Error(data.error || 请求失败); } displayResult(data); } catch (err) { showError(err.message); } });4.7 安装依赖并启动项目在eclipse-viewer目录下创建虚拟环境并安装依赖。python -m venv venv source venv/bin/activate # Windows 使用 venv\Scripts\activate pip install flask requests timezonefinder启动 Flask 服务python app.py打开浏览器访问http://127.0.0.1:5000输入一个地址点击查询。如果一切正常你会看到页面展示查询结果并在 Canvas 中绘制出对应的日食模拟图。当食分为 0 时月亮圆完全移出太阳圆图片显示为一轮完整的太阳当食分为 1 时太阳被完全遮住中心会有一点日冕光效。5. 常见问题与排查思路开发过程中你可能会遇到下面这些问题我整理成了一张排查表问题现象常见原因解决思路地址解析失败返回 “无法解析该地址”地址太模糊或 Nominatim 暂时无结果输入更完整的地址例如“北京市朝阳区三里屯街道”检查网络是否畅通请求/api/eclipse超时Nominatim 请求过慢或受限设置更长的超时时间检查 User-Agent 是否合规开发环境可先准备一份示例经纬度返回的时区不正确时区数据库不完整更新timezonefinder的数据或手动维护一个城市到时区的映射表日食食分计算结果不准确中心线数据过少、距离估算模型太简化增加中心线坐标点从专业数据源获取中心线坐标改用更精确的天文计算库前端 Canvas 不显示Canvas 宽高被 CSS 覆盖或 JS 报错打开浏览器控制台查看报错确保canvas标签有明确宽高检查 JS 中获取元素是否成功部署后接口 404静态文件路径或路由前缀问题检查 Flask 的static_folder和路由使用url_for生成静态资源路径5.1 一个典型的地址解析报错常见报错如下requests.exceptions.HTTPError: 403 Client Error: Forbidden这通常是因为 Nominatim 服务拒绝了请求。原因可能是请求没有设置 User-Agent 头。请求频率过高被临时限流。使用了数据中心 IP被服务方屏蔽。排查时先检查代码中是否设置了headers{User-Agent: eclipse-viewer-demo/1.0}。如果本机测试没问题但服务器上被拦截可以考虑缓存地址解析结果减少请求次数。5.2 时区数据的坑timezonefinder在安装时可能需要编译原生扩展如果安装失败可以尝试pip install --upgrade pip pip install timezonefinder如果你的 Python 版本较老建议先升级到 Python 3.8 以上。仍然不行的话就从简处理默认使用 UTC或者只针对你关心的几个城市硬编码时区。对于教学示例来说运行时区不准确不会影响核心逻辑。6. 最佳实践与工程建议6.1 日食数据不要硬编码示例代码把日食事件放在一个 JSON 文件中这是为了便于教学。真实项目中你可能会遇到多个日食事件、历史数据或未来预测数据。建议把数据放入数据库或缓存系统并提供一个管理后台方便运营人员录入新事件。如果你的项目需要精确预测建议直接使用专业天文库或权威数据源而不是自己写一个简化距离模型。本文的模型只能用于演示产品交互不能用于真实天文观测指导。6.2 地理编码服务的选择与合规Nominatim 适合开发环境和小流量场景但不可以大规模商用也不应该高频调用。生产环境建议使用有正式授权、符合当地法规的地图服务。把 API Key 放在后端环境变量中不要暴露在前端。对用户输入做长度限制和敏感词过滤避免恶意请求。增加服务端缓存减少重复调用。6.3 接口设计建议后端接口设计要尽量简单、稳定。建议使用GET /api/eclipse还是POST /api/eclipse如果查询参数简单用 GET 就行如果输入复杂可以用 POST。返回结构统一为{code, message, data}或 HTTP 状态码 JSON便于前端处理。为接口增加请求日志记录请求地址、耗时和结果方便排错。6.4 前端绘制的性能与体验Canvas 绘制日食模拟图时不需要每帧重绘所以性能压力很小。但如果你后续增加“时间轴动画”模拟日食从初亏到复圆的连续过程就需要考虑使用requestAnimationFrame驱动动画。控制帧率避免低端设备卡顿。将太阳、月亮的绘制参数抽成独立函数方便复用和单元测试。为色盲用户提供文字描述选项不要只依赖颜色区分日食阶段。6.5 安全注意事项这类工具涉及外部 API 调用需要注意后端请求第三方接口时始终设置超时时间。对用户传入的地址做转义防止构造恶意 URL。日食数据如果来自外部文件校验 JSON 格式避免解析异常。部署到公网时使用 HTTPS并做好接口限流防止被刷。7. 总结与后续扩展方向这篇文章从一个“输入地址看日食”的创意出发完整实现了一个 Web 应用。我们做了下面几件事介绍了日食类型和“地址不同、日食不同”的原因。设计了后端 Flask API实现地址解析、距离估算、时区转换。编写了前端页面使用 Canvas 绘制太阳和月球的遮挡效果。整理了常见报错和工程落地建议。如果你希望继续完善这个项目可以考虑以下方向使用真实日食中心线数据替换演示 JSON。增加时间轴模拟日食从初亏到复圆的过程。接入地图组件用户可以直接在地图上点击选择观测点。添加分享功能生成日食模拟效果的海报或链接。使用更精确的天文库例如 Skyfield、Astronomia实现实时日月位置计算。如果你也想做一个类似的“地址 → 天文现象模拟”工具建议先从小范围场景开始用静态数据把完整链路跑通再慢慢替换成真实计算。希望这篇教程能帮你理清思路也欢迎在实际开发中根据你的业务需求继续调整和扩展。