简介:一份源自计算机设计大赛的完整前端源码,面向参赛选手、数字媒体开发者及对文化可视化感兴趣的读者,旨在用网页交互形式再现唐代诗人群像。压缩包共109个文件,大小44.68MB,核心包括6个HTML页面、4个CSS样式表、9个JavaScript脚本与多个JSON数据文件,配以81张PNG图片和少量JPG素材,分别承担页面结构、视觉风格、诗词数据交互和诗人画像展示等职责。项目将诗人传记、历史文献与诗歌作品整合为可视化场景,覆盖人物关系、诗词词云与年代脉络等模块,适合作为数字技术在文化传承领域应用的参考范例。源码结构清晰,可直接运行调试,也可在现有交互与样式基础上延展新功能,例如增加诗人分类筛选或诗词检索。目前已有160人学习,可帮助理解从数据整理到前端呈现的完整实现思路,对备战计算机设计大赛或同类文化数字化项目尤具参考价值。
1. 为什么“诗人群像”最终拼的是工程而不是诗
计算机设计大赛里,“诗意千年-唐朝诗人群像的数字展现”这类选题每年都有大量参赛者选它。它的入口门槛看着不高:把几十个唐代诗人的生平、籍贯、名篇做成页面,配上地图和年表,视觉上就完成了大半。可一旦进入评审和演示环节,拉开差距的往往不是诗歌解读的深度,而是数据怎么组织、地图轨迹怎么复用、时间轴怎么驱动场景切换,以及源码包换一台电脑能不能直接跑起来。
这个标题真正指向的是数字人文(Digital Humanities)里最常见的一类需求:把一群历史人物的空间分布、时间跨度和社交关系,转换成可交互的Web可视化。它同时涉及事件型时序数据建模、地理坐标映射、多视图联动和资源路径兼容。适合你动手的切入点不是“再写一个诗人介绍页”,而是把诗人当数据,把浏览器当展馆,把源码包当成一份可交付的数据产品。下文按数据、呈现、工程、复用四条线展开,中间会给出可直接复制的代码和参数。
2. 把诗人抽象成三维数据模型:空间、时间与关系层
2.1 一份可计算的诗人数据应该长什么样
先明确一点:数字展现的第一步不是写组件,而是把诗人的生平文本转成结构化数据。常见的做法是建一张人物表(JSON或SQLite),每个诗人至少包含三类维度。
- 空间维度:籍贯、出仕地、贬谪地、游历地,每个地点要同时保留地名(用于展示)和经纬度或GeoJSON行政编码(用于地图映射)。
- 时间维度:生卒年、关键事件年份(科举、入仕、贬谪、代表作发表年份)。历史纪年法需要注意,唐代用年号加帝号,转换为公元前后一定要建一张对照表。
- 关系维度:诗人之间的交游、唱和、师承。关系要用边表存,不能塞进人物详情字段里,否则后面做力导图时还要二次拆分。
这个阶段最常见的错误是直接把百度百科文本复制进JSON的bio字段,认为“数据”已经做完了。实际上,文本型字段在可视化阶段几乎无法直接驱动任何视图。数字展现看重的是可枚举的离散值:能把一个人画成地图上的点,靠的是坐标;能把一个朝代画成一条流动的时间线,靠的是年份;能把诗人群像画成网络,靠的是明确的“谁和谁有关系”。
2.1.1 事件型模型替代档案型模型
如果你打开过好的历史可视化项目源码,会发现他们的数据极少是“诗人-简介”的二级结构,而是“事件数组”。一个诗人对应多条事件,每条事件带年份、地点、事件类型和关联人。这种事件型模型(event-sourced model)的好处是:后续做轨迹动画时,直接按年份排序所有事件,折线就是行踪;做筛选时,按事件类型过滤,比按人物过滤更精细。
对应字段可参考下面这个结构:
{ "id": "libai", "name": "李白", "dynasty": "唐", "birthYear": 701, "deathYear": 762, "events": [ { "year": 724, "type": "游历", "place": "成都", "lat": 30.67, "lng": 104.07 }, { "year": 742, "type": "入仕", "place": "长安", "lat": 34.34, "lng": 108.94 }, { "year": 755, "type": "避乱", "place": "当涂", "lat": 31.56, "lng": 118.49 } ], "relations": [ { "targetId": "dufu", "type": "挚友" }, { "targetId": "menghaoran", "type": "师友" } ] }提示:真做数据清洗时,历史地名与今天经纬度的对应关系是最大的坑。唐代的“长安”在今天西安市区,但同一朝代的“洛阳”在今天洛阳,不能用今天的行政中心坐标直接套,需要按历史地理数据校正。
2.2 经纬度、行政编码和地名的三种匹配策略
地图可视化的数据管线是:地名 → 坐标 → 画点到图上。这里有三条路可以走:
- 硬编码坐标表。维护一份
{ "长安": [108.94, 34.34], "洛阳": [112.45, 34.62] }。适合诗人数量在百人以内、地点五十个上下的作品级项目,稳定且完全可离线。 - 调用地理编码API。适合数据量大但能接受网络请求的方案。缺点:请求有配额,且在高德/百度地图的坐标系与WGS84之间需要纠偏。
- GeoJSON + 行政名匹配。注册
GeoJSON的地区子集,用名称关联。适合渲染区域热力,不适合画人物点。
就“诗人群像”这个题目来说,硬编码坐标表是最可靠的。项目源码被评审老师解压后,可能在没有外网的教室演示,硬编码坐标保证地图层完全离线、零延迟。坐标数据建议单独抽成一个coords.js,与人物数据分离,便于地图换底图时统一替换。
3. 用 ECharts 搭三件套:轨迹图、时间轴与关系力导图
3.1 为什么选 ECharts 而不是从零写 Canvas
市面上能用于数字人文可视化的方案不少:D3.js灵活度高,适合表达复杂叙事;Mapbox偏地图渲染,重且需要Token;Three.js方案适合做三维沉浸。但对一个竞赛项目源码来说,ECharts有着别人比不了的优势:lines / effectScatter / timeline / graph四种系列开箱即用,GeoJSON地图支持registerMap,配置项写成普通对象就能被setOption消费。学习成本和后期改造成本都低。
具体拆解,三个核心视图在这类作品里几乎是标配:
- 诗人行踪轨迹图:用
series-lines画流动路线,effectScatter高亮途经城市。 - 生平时间轴:用ECharts的
timeline组件,配合年份区间重绘地图上的数据。 - 人物关系网:用
graph系列,edges数据直接对应前面建的relations边表。
3.1.1 轨迹图的最小可运行配置
先看一段可以直接放进index.html运行的ECharts轨迹图层:
const ROAM_COORD = { 成都: [104.07, 30.67], 长安: [108.94, 34.34], 当涂: [118.49, 31.56], }; const libaiPath = [ { coord: ROAM_COORD['成都'], year: 724 }, { coord: ROAM_COORD['长安'], year: 742 }, { coord: ROAM_COORD['当涂'], year: 755 }, ]; const option = { geo: { map: 'china', roam: true, zoom: 1.2, itemStyle: { areaColor: '#f5e8c7', borderColor: '#8c6b3f' }, }, series: [ { name: '李白行踪', type: 'lines', coordinateSystem: 'geo', data: [ { coords: [ROAM_COORD['成都'], ROAM_COORD['长安']] }, { coords: [ROAM_COORD['长安'], ROAM_COORD['当涂']] }, ], lineStyle: { color: '#a0522d', width: 2, curveness: 0.2 }, effect: { show: true, period: 6, trailLength: 0.4, symbol: 'arrow' }, }, { name: '关键节点', type: 'effectScatter', coordinateSystem: 'geo', data: libaiPath.map((p) => ({ name: p.year + '年', value: p.coord.concat(30) })), symbolSize: 8, rippleEffect: { brushType: 'stroke' }, }, ], }; myChart.setOption(option);这段代码有两个参数值得说明。curveness控制两坐标之间连线的弯曲程度,设置成0.2能在两条轨迹重叠时拉开视觉层级,否则长安到成都和长安到当涂的线会交叉成难看的锐角。effectScatter的value数组里第三个值是散点的视觉权重,配合symbolSize函数可做年份越大点越大的表达,具体写法是把symbolSize改成回调函数(val) => Math.max(6, (val[2] / 30))。
3.2 timeline 与地图的数据联动写法
时间轴是数字人文项目的标志性交互。ECharts的timeline不是简单的滑块播放,它每次变化都会触发一次setOption,因此做联动时,要把每个年份对应的地图点数据预先切片成数组:
const yearData = [724, 742, 755, 762]; const scatterData = { 724: [['成都', 104.07, 30.67, 22]], 742: [['长安', 108.94, 34.34, 35]], 755: [['当涂', 118.49, 31.56, 18]], 762: [['当涂', 118.49, 31.56, 18]], }; const option = { baseOption: { timeline: { data: yearData, axisType: 'category', autoPlay: true, interval: 3000, bottom: 20, label: { formatter: (v) => '公元' + v + '年' }, }, geo: { map: 'china', roam: false }, series: [{ type: 'effectScatter', coordinateSystem: 'geo' }], }, options: yearData.map((year) => ({ series: [{ data: scatterData[year] }], })), }; myChart.setOption(option);注意options数组和baseOption的兄弟关系是ECharts timeline的固定要求。baseOption里放不变的地图底图、坐标系声明和全局样式;options数组里放每年变化的series.data。数据切片后,在timeline播放时地图上的点位会自动替换,无需手动监听timelinechanged事件再调用setOption。
3.2.1 关系力导图的 data 与 links 分离
如果直接把人物关系写进series.data的每个节点里,图一复杂就会卡顿。ECharts的图关系数据必须拆成节点数组和连线数组:
const graphData = { nodes: [ { id: 'libai', name: '李白', symbolSize: 50, category: 0 }, { id: 'dufu', name: '杜甫', symbolSize: 42, category: 1 }, { id: 'meng', name: '孟浩然', symbolSize: 30, category: 2 }, ], links: [ { source: 'libai', target: 'dufu', value: '挚友' }, { source: 'libai', target: 'meng', value: '师友' }, ], }; graphOption = { series: [ { type: 'graph', layout: 'force', roam: true, draggable: true, categories: [{ name: '代表诗人' }, { name: '知交' }, { name: '师友' }], force: { repulsion: 300, edgeLength: [50, 120] }, data: graphData.nodes, edges: graphData.links, label: { show: true, position: 'right' }, }, ], };repulsion是节点间斥力系数,值越大节点分布越散。对二十人左右的诗人关系网,300到500之间的手感最好,太小组团挤在一起,太大则各节点飘到画布边缘。edgeLength设置成[50, 120]数组时,ECharts会把短边压缩、长边拉伸,表现不同类型的关系强度。
4. 把源码包跑起来:目录结构、构建链路与zip解压排错
4.1 一个能拿去参赛的源码包目录应该长什么样
“完整源码.zip”这个后缀,背后是一套可交付的工程约束。评审老师拿到压缩包后,第一步是解压,第二步是找README或index.html,第三步是双击或起本地服务。这三步里任何一步出问题,代码写得再漂亮也没用。一个稳妥的参赛作品目录结构是这样:
poetry-tang/ ├── index.html ├── README.md ├── assets/ │ ├── data/ │ │ ├── poets.json │ │ ├── coords.js │ │ └── events.json │ ├── geo/ │ │ └── china.json │ ├── css/ │ │ └── main.css │ └── js/ │ ├── libs/echarts.min.js │ ├── map.js │ ├── timeline.js │ ├── graph.js │ └── main.js └── docs/ └── 设计说明.md这个结构的核心决策有两个:一是echarts.min.js必须放在本地libs下,不能用CDN,因为评审现场可能断网;二是coords.js和poets.json分开,坐标数据变更时不用动诗人数据文件,地图换底图时也不会误伤人物信息。如果你的项目用了构建工具,前面说的问题依然存在,打包后的产物里必须把geo目录的JSON单独拷贝到根目录,否则fetch的相对路径会指向错误层级。
4.2 本地跑通三类入口的命令与参数
4.2.1 纯静态版本(无构建工具)
直接用VS Code的Live Server插件打开index.html,或起一个Python HTTP服务:
cd poetry-tang python3 -m http.server 8080然后浏览器访问http://localhost:8080。注意不要直接双击index.html用file://协议打开,ECharts的registerMap和fetch('assets/geo/china.json')在file://下会被CORS策略拦截,控制台通常报Failed to load resource。这是新手把源码包发给别人后“打不开”的头号原因。
4.2.2 Vite / webpack 工程化版本
如果项目用Vite构建,在vite.config.js里需要配置public目录存放静态JSON:
import { defineConfig } from 'vite'; export default defineConfig({ base: './', server: { port: 5173, open: true }, });base: './'决定了打包后资源用相对路径引用,这样dist目录整体拷给别人时,直接双击index.html也能相对找到JS和CSS,不用非得起服务。
4.2.3 zip 解压后常见的三个失败点
- 文件名乱码:Windows上压缩时用GBK编码,macOS/Linux解压出中文文件名乱码,这是zip包最常见的问题。解决方法是压缩前使用7-Zip选择
UTF-8,或在解压时指定编码unzip -O gbk archive.zip。 - 解压后目录层级不对:很多选手直接压缩项目文件夹的父级,评审解压后多套一层目录。规范做法是在项目根目录外一层选中
poetry-tang文件夹压缩,保证解压出来第一层就是index.html。 - 资源路径404:
assets/js/map.js被引入时写成了./js/map.js,根目录不同就报错。建议统一用相对当前HTML的./或../路径,避免绝对路径/assets。
4.3 数据管线脚本:从百科文本到JSON
手工编写上百个诗人的结构化数据不现实,但写一个小型Node脚本清洗CSV,是参赛源码里极好的加分项。下面这个脚本读取三列表格,输出可直接被ECharts读的JSON:
const fs = require('fs'); const csvData = fs.readFileSync('./poets.csv', 'utf-8'); const lines = csvData.trim().split('\n').slice(1); const poets = []; for (const line of lines) { const [name, birth, death, place, lat, lng] = line.split(','); poets.push({ name: name.trim(), birthYear: Number(birth), deathYear: Number(death), hometown: { place: place.trim(), lat: Number(lat), lng: Number(lng) }, }); } fs.writeFileSync('./poets.json', JSON.stringify(poets, null, 2)); console.log(`已生成 ${poets.length} 位诗人数据`);这段脚本的逻辑是按行切割CSV,去掉表头,把字符串类型的年份转成数字类型。关键点是Number(birth)这行:ECharts对series.data里的年份字符串有时会按分类轴处理,导致时间轴排序错乱,提前转成Number可避免后续排查成本。
5. 把唐诗人群像方案迁移到任意历史人物群体
5.1 一组通用的 Schema 改写技巧
这个项目的终极价值是其数据模型可迁移。把“唐代诗人”换成“宋代词人”“明代画家”“五四知识分子”,代码层几乎不用动,只需要把poets.json里的字段按新人物替换。真正要改的只有三处:一是coords.js里硬编码坐标如果换成了新的活动地域,要补充地名;二是timeline的年份区间从618-907改为新朝代的起止;三是geo底图若涉及朝代疆域变化,可以换成当时的区域GeoJSON或直接用今天的中国地图做底板。
以“宋代词人”为例,poets.json里面苏轼的一条数据可以是:
{ "name": "苏轼", "birthYear": 1037, "deathYear": 1101, "hometown": { "place": "眉山", "lat": 30.05, "lng": 103.83 }, "events": [ { "year": 1057, "type": "进士及第", "place": "开封", "lat": 34.79, "lng": 114.3 }, { "year": 1079, "type": "贬谪", "place": "黄州", "lat": 30.45, "lng": 114.87 } ] }数据替换后,timeline切片逻辑不需要动,因为yearData是从poets.json里动态提取的。这个技巧放到竞赛评审中,往往比多做一个交互动效更能体现“工程复用”意识。
5.2 用筛选器按年份区间切片,替代无限下拉
最后一个值得给到源码包的进阶交互:在时间轴旁加一个区间筛选器,按“初唐/盛唐/中唐/晚唐”切片诗人。实现方式是在ECharts外面维护一个range状态,每次timeline切换或区间变化时,先过滤poets.json,再重新拼装scatterData。这样地图上的点位会随年代密度变化,一眼看出盛唐诗人数量峰值在哪个时期。
const activeRange = [713, 766]; // 盛唐约数 const filteredPoets = poets.filter( (p) => p.birthYear <= activeRange[1] && p.deathYear >= activeRange[0] ); const points = filteredPoets.map((p) => ({ name: p.name, value: [p.hometown.lng, p.hometown.lat, 1], })); myChart.setOption({ series: [{ id: 'poetPoints', data: points }], });这段代码里的series: [{ id: 'poetPoints' }]值得单独说明:ECharts 5之后,setOption支持按id精准更新指定系列,而不是全量覆盖series数组。这意味着区间筛选器每次滑动,无需重建地图底图,只更新点位系列,大数量级下依旧能保持60帧。源码包里保留这个写法,在演示“盛唐诗人密度高于初唐”时,数据说服力远超静态图片。
本文还有配套的精品资源,点击获取