Compare commits
15
Commits
32bb48cc0b
...
eb165d03c5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb165d03c5 | ||
|
|
2a173833d0 | ||
|
|
213b2fe7a1 | ||
|
|
159b98d232 | ||
|
|
2687e5fd95 | ||
|
|
fade9eedc2 | ||
|
|
41d68e087a | ||
|
|
91d82d27bf | ||
|
|
18a2077369 | ||
|
|
ed7d377eb6 | ||
|
|
52111b6cce | ||
|
|
14162ba7d0 | ||
|
|
d4f27f3857 | ||
|
|
b63fc980f9 | ||
|
|
e3a55bfc01 |
File diff suppressed because one or more lines are too long
+17
-2
@@ -165,7 +165,7 @@ export class App {
|
|||||||
this.interactives.binder.push(m);
|
this.interactives.binder.push(m);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
this.timeline.cards.forEach((c) => this.interactives.timeline.push(c.front, c.back));
|
this.timeline.cards.forEach((c) => this.interactives.timeline.push(c.mesh));
|
||||||
// 地理视图由 Cesium 自行拾取,不参与 Three.js 射线检测
|
// 地理视图由 Cesium 自行拾取,不参与 Three.js 射线检测
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +258,18 @@ export class App {
|
|||||||
return (2 * Math.tan(halfFov) * dist * aspect) / canvas.clientWidth;
|
return (2 * Math.tan(halfFov) * dist * aspect) / canvas.clientWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 时间轴:按当前相机可视区域刷新纹理窗口(拖拽/缩放/居中动画时连续跟随)
|
||||||
|
_updateTimelineTextures() {
|
||||||
|
if (this.view !== 'timeline') return;
|
||||||
|
const dist = this.camera.position.distanceTo(this.controls.target);
|
||||||
|
const halfFov = THREE.MathUtils.degToRad(this.camera.fov / 2);
|
||||||
|
const el = this.renderer.domElement;
|
||||||
|
const aspect = el.clientWidth / el.clientHeight;
|
||||||
|
const halfW = Math.tan(halfFov) * dist * aspect;
|
||||||
|
// 时间轴相机始终在 z 轴看向原点(旋转已锁定),可见世界 x ∈ [-halfW, halfW]
|
||||||
|
this.timeline.setVisibleWorldRange(-halfW, halfW);
|
||||||
|
}
|
||||||
|
|
||||||
_hitTest() {
|
_hitTest() {
|
||||||
const list = this.interactives[this.view];
|
const list = this.interactives[this.view];
|
||||||
if (!list || !list.length) return null;
|
if (!list || !list.length) return null;
|
||||||
@@ -388,6 +400,7 @@ export class App {
|
|||||||
const limit = TIMELINE_SPREAD + 0.6;
|
const limit = TIMELINE_SPREAD + 0.6;
|
||||||
this.timelineOffset = clamp(-this.timeline.getCardX(this.selected), -limit, limit);
|
this.timelineOffset = clamp(-this.timeline.getCardX(this.selected), -limit, limit);
|
||||||
this.timeline.group.position.x = this.timelineOffset;
|
this.timeline.group.position.x = this.timelineOffset;
|
||||||
|
this.timeline.refreshTextures();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isGlobe) {
|
if (!isGlobe) {
|
||||||
@@ -406,7 +419,7 @@ export class App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_applyRotationLimits(pose) {
|
_applyRotationLimits(pose) {
|
||||||
const LIMIT = (20 * Math.PI) / 180; // ±20°
|
const LIMIT = (10 * Math.PI) / 180; // ±10°
|
||||||
const dx = pose.pos.x - pose.target.x;
|
const dx = pose.pos.x - pose.target.x;
|
||||||
const dy = pose.pos.y - pose.target.y;
|
const dy = pose.pos.y - pose.target.y;
|
||||||
const dz = pose.pos.z - pose.target.z;
|
const dz = pose.pos.z - pose.target.z;
|
||||||
@@ -444,6 +457,8 @@ export class App {
|
|||||||
|
|
||||||
this.binder.update(dt);
|
this.binder.update(dt);
|
||||||
this.timeline.update(dt);
|
this.timeline.update(dt);
|
||||||
|
this.timeline.drainPending();
|
||||||
|
this._updateTimelineTextures();
|
||||||
|
|
||||||
if (this.cameraTween) {
|
if (this.cameraTween) {
|
||||||
const t = this.cameraTween;
|
const t = this.cameraTween;
|
||||||
|
|||||||
+146
-85
@@ -7,9 +7,6 @@ const IMAGERY_URL =
|
|||||||
const TERRAIN_URL =
|
const TERRAIN_URL =
|
||||||
'https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer';
|
'https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer';
|
||||||
|
|
||||||
// 点击标记后飞到的目标高度(米,与初始视图一致,保证左键拖拽始终是“整球旋转”)
|
|
||||||
const FLY_TO_HEIGHT = 7.2e6;
|
|
||||||
|
|
||||||
// 计算多边形集合的球面面积(km²),用于按国土面积分级国家名字号
|
// 计算多边形集合的球面面积(km²),用于按国土面积分级国家名字号
|
||||||
function sphericalAreaKm2(polys) {
|
function sphericalAreaKm2(polys) {
|
||||||
let area = 0;
|
let area = 0;
|
||||||
@@ -36,29 +33,66 @@ function sphericalAreaKm2(polys) {
|
|||||||
return area * R * R;
|
return area * R * R;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成圆角黑色背景 canvas(尺寸按文字实际测量宽度)
|
// 生成便签 canvas:黄底圆角 + 左侧箭头 + 黑色加粗文字(整张图,箭头尖端指向坐标)
|
||||||
function makeRoundedBg(text, fontSize) {
|
function makeStickyNote(text, fontSize, selected = false) {
|
||||||
const padX = 12, padY = 7, radius = 10;
|
const padX = 10, padY = 6, radius = 8, arrowW = 12, arrowH = 16, S = 2; // S: 2x 超采样
|
||||||
|
const font = `bold ${fontSize}px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif`;
|
||||||
const m = document.createElement('canvas');
|
const m = document.createElement('canvas');
|
||||||
const mctx = m.getContext('2d');
|
const mctx = m.getContext('2d');
|
||||||
mctx.font = `${fontSize}px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif`;
|
mctx.font = font;
|
||||||
const textW = mctx.measureText(text).width;
|
const textW = mctx.measureText(text).width;
|
||||||
const w = Math.max(1, Math.ceil(textW + padX * 2));
|
const bodyW = Math.max(1, Math.ceil(textW + padX * 2));
|
||||||
|
const w = arrowW + bodyW;
|
||||||
const h = Math.ceil(fontSize + padY * 2);
|
const h = Math.ceil(fontSize + padY * 2);
|
||||||
const c = document.createElement('canvas');
|
const c = document.createElement('canvas');
|
||||||
c.width = w;
|
c.width = w * S;
|
||||||
c.height = h;
|
c.height = h * S;
|
||||||
const ctx = c.getContext('2d');
|
const ctx = c.getContext('2d');
|
||||||
|
ctx.scale(S, S);
|
||||||
|
|
||||||
|
// 阴影
|
||||||
|
ctx.shadowColor = 'rgba(0, 0, 0, 0.20)';
|
||||||
|
ctx.shadowBlur = 5;
|
||||||
|
ctx.shadowOffsetY = 2;
|
||||||
|
|
||||||
|
// 箭头 + 圆角矩形一体化路径(箭头尖端在左侧 x=0,指向坐标)
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(radius, 0);
|
ctx.moveTo(0, h / 2); // 箭头尖端
|
||||||
ctx.arcTo(w, 0, w, h, radius);
|
ctx.lineTo(arrowW, h / 2 - arrowH / 2); // 箭头上沿
|
||||||
ctx.arcTo(w, h, 0, h, radius);
|
ctx.lineTo(arrowW, radius); // 左边缘上部
|
||||||
ctx.arcTo(0, h, 0, 0, radius);
|
ctx.arcTo(arrowW, 0, arrowW + radius, 0, radius); // 左上圆角
|
||||||
ctx.arcTo(0, 0, w, 0, radius);
|
ctx.lineTo(w - radius, 0); // 上边
|
||||||
ctx.closePath();
|
ctx.arcTo(w, 0, w, radius, radius); // 右上圆角
|
||||||
ctx.fillStyle = 'rgba(20, 16, 13, 0.88)';
|
ctx.lineTo(w, h - radius); // 右边
|
||||||
|
ctx.arcTo(w, h, w - radius, h, radius); // 右下圆角
|
||||||
|
ctx.lineTo(arrowW + radius, h); // 底边
|
||||||
|
ctx.arcTo(arrowW, h, arrowW, h - radius, radius); // 左下圆角
|
||||||
|
ctx.lineTo(arrowW, h / 2 + arrowH / 2); // 左边缘下部
|
||||||
|
ctx.closePath(); // 闭合回箭头尖端
|
||||||
|
ctx.fillStyle = '#e0c46a';
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
return { canvas: c, textW };
|
|
||||||
|
// 选中时加黑边
|
||||||
|
if (selected) {
|
||||||
|
ctx.shadowColor = 'transparent';
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.shadowOffsetY = 0;
|
||||||
|
ctx.strokeStyle = '#14100d';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 黑色加粗文字(无阴影)
|
||||||
|
ctx.shadowColor = 'transparent';
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.shadowOffsetY = 0;
|
||||||
|
ctx.fillStyle = '#14100d';
|
||||||
|
ctx.font = font;
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(text, arrowW + bodyW / 2, h / 2 + 1);
|
||||||
|
|
||||||
|
return { canvas: c, scale: 1 / S, width: w, height: h };
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CesiumGlobe {
|
export class CesiumGlobe {
|
||||||
@@ -77,6 +111,36 @@ export class CesiumGlobe {
|
|||||||
if (this._created) return;
|
if (this._created) return;
|
||||||
this._created = true;
|
this._created = true;
|
||||||
|
|
||||||
|
// 调整实体渲染顺序:先渲染标签(城市/国家名),再渲染 Billboard(标记便签),
|
||||||
|
// 让词条标记始终显示在城市/国家名的上层。
|
||||||
|
Cesium.DataSourceDisplay.defaultVisualizersCallback = function (
|
||||||
|
scene,
|
||||||
|
entityCluster,
|
||||||
|
dataSource,
|
||||||
|
) {
|
||||||
|
const entities = dataSource.entities;
|
||||||
|
return [
|
||||||
|
new Cesium.LabelVisualizer(entityCluster, entities),
|
||||||
|
new Cesium.BillboardVisualizer(entityCluster, entities),
|
||||||
|
new Cesium.GeometryVisualizer(
|
||||||
|
scene,
|
||||||
|
entities,
|
||||||
|
dataSource._primitives,
|
||||||
|
dataSource._groundPrimitives,
|
||||||
|
),
|
||||||
|
new Cesium.ModelVisualizer(scene, entities),
|
||||||
|
new Cesium.Cesium3DTilesetVisualizer(scene, entities),
|
||||||
|
new Cesium.PointVisualizer(entityCluster, entities),
|
||||||
|
new Cesium.PathVisualizer(scene, entities),
|
||||||
|
new Cesium.PolylineVisualizer(
|
||||||
|
scene,
|
||||||
|
entities,
|
||||||
|
dataSource._primitives,
|
||||||
|
dataSource._groundPrimitives,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
const container = document.getElementById('cesium-container');
|
const container = document.getElementById('cesium-container');
|
||||||
|
|
||||||
this.viewer = new Cesium.Viewer(container, {
|
this.viewer = new Cesium.Viewer(container, {
|
||||||
@@ -167,77 +231,73 @@ export class CesiumGlobe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_buildMarkers() {
|
_buildMarkers() {
|
||||||
const viewer = this.viewer;
|
|
||||||
const gold = Cesium.Color.fromCssColorString('#e0c46a');
|
|
||||||
const ivory = Cesium.Color.fromCssColorString('#e8d9b4');
|
|
||||||
const ink = Cesium.Color.fromCssColorString('#14100d');
|
|
||||||
|
|
||||||
ENTRIES.forEach((entry, i) => {
|
ENTRIES.forEach((entry, i) => {
|
||||||
const entity = viewer.entities.add({
|
const position = Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat);
|
||||||
position: Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat),
|
const noteNormal = makeStickyNote(entry.name, 19, false);
|
||||||
point: {
|
const noteSelected = makeStickyNote(entry.name, 19, true);
|
||||||
pixelSize: 10, // 标记宽一点
|
this.markers.push({ position, noteNormal, noteSelected, index: i });
|
||||||
color: gold, // 金色圆形
|
|
||||||
outlineColor: ink, // 外侧 1px 黑边
|
|
||||||
outlineWidth: 1,
|
|
||||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
|
||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
||||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 1e7),
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
text: entry.name,
|
|
||||||
font: '19px "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif',
|
|
||||||
fillColor: gold,
|
|
||||||
outlineColor: ink,
|
|
||||||
outlineWidth: 3,
|
|
||||||
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
|
||||||
horizontalOrigin: Cesium.HorizontalOrigin.LEFT,
|
|
||||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
|
||||||
pixelOffset: new Cesium.Cartesian2(10, 0),
|
|
||||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
|
||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
||||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 1e7),
|
|
||||||
show: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
this._initMarkerLayer();
|
||||||
|
}
|
||||||
|
|
||||||
entity.entryIndex = i;
|
// 便签用 DOM 覆盖层渲染:始终在城市/国家名上层,且缩放到最近也不会漂移
|
||||||
|
_initMarkerLayer() {
|
||||||
|
const container = document.getElementById('cesium-container');
|
||||||
|
this._markerLayer = document.createElement('div');
|
||||||
|
this._markerLayer.id = 'marker-layer';
|
||||||
|
container.appendChild(this._markerLayer);
|
||||||
|
|
||||||
// 选中时显示的圆角黑色背景
|
for (const m of this.markers) {
|
||||||
const bg = makeRoundedBg(entry.name, 19);
|
const el = document.createElement('div');
|
||||||
const bgEntity = viewer.entities.add({
|
el.className = 'marker-note';
|
||||||
position: Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat),
|
el.style.width = `${m.noteNormal.width}px`;
|
||||||
billboard: {
|
el.style.height = `${m.noteNormal.height}px`;
|
||||||
image: bg.canvas,
|
el.dataset.index = m.index;
|
||||||
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
m.el = el;
|
||||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
m.normalUrl = m.noteNormal.canvas.toDataURL();
|
||||||
pixelOffset: new Cesium.Cartesian2(10 + bg.textW / 2, 0),
|
m.selectedUrl = m.noteSelected.canvas.toDataURL();
|
||||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
el.style.backgroundImage = `url("${m.normalUrl}")`;
|
||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
el.addEventListener('click', (e) => {
|
||||||
show: false,
|
e.stopPropagation();
|
||||||
},
|
this.onPick?.(m.index);
|
||||||
});
|
});
|
||||||
|
this._markerLayer.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
this.markers.push({ entity, bg: bgEntity, index: i });
|
this.viewer.scene.postRender.addEventListener(() => this._updateMarkerLayer());
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// 每帧更新便签屏幕位置(相机移动/缩放/飞行时跟随)
|
||||||
|
_updateMarkerLayer() {
|
||||||
|
const scene = this.viewer.scene;
|
||||||
|
const camera = scene.camera;
|
||||||
|
const outOfRange = camera.positionCartographic.height > 1e7; // 最远 1 万 km 内显示
|
||||||
|
|
||||||
|
for (const m of this.markers) {
|
||||||
|
const el = m.el;
|
||||||
|
if (!el) continue;
|
||||||
|
if (outOfRange) { el.style.display = 'none'; continue; }
|
||||||
|
|
||||||
|
const win = Cesium.SceneTransforms.worldToWindowCoordinates(scene, m.position);
|
||||||
|
if (!win) { el.style.display = 'none'; continue; }
|
||||||
|
|
||||||
|
// 背面(远半球)隐藏:dot(P, C) <= |P|² 时为背对地球
|
||||||
|
const dotPC = Cesium.Cartesian3.dot(m.position, camera.positionWC);
|
||||||
|
const r2 = Cesium.Cartesian3.magnitudeSquared(m.position);
|
||||||
|
if (dotPC <= r2) { el.style.display = 'none'; continue; }
|
||||||
|
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.style.left = `${win.x}px`;
|
||||||
|
el.style.top = `${win.y}px`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_setupPick() {
|
_setupPick() {
|
||||||
const viewer = this.viewer;
|
const viewer = this.viewer;
|
||||||
// 移除 Viewer 默认的单击选中与双击“追踪实体”:否则双击后会锁定相机(定住)
|
// 移除 Viewer 默认的单击选中与双击“追踪实体”:否则双击后会锁定相机(定住)
|
||||||
|
// 标记便签已改为 DOM 覆盖层,点击由 DOM 元素自行处理
|
||||||
viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||||
viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
|
viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
|
||||||
|
|
||||||
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
|
||||||
handler.setInputAction((movement) => {
|
|
||||||
const picked = viewer.scene.pick(movement.position);
|
|
||||||
if (Cesium.defined(picked) && picked.id && picked.id.entryIndex !== undefined) {
|
|
||||||
const index = picked.id.entryIndex;
|
|
||||||
this.onPick?.(index);
|
|
||||||
this.flyToEntry(index);
|
|
||||||
}
|
|
||||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
|
||||||
this._handler = handler;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 相机平滑飞至某个标记上空并居中:回到与初始一致的高度与北朝上方向,左键拖拽手感不变
|
// 相机平滑飞至某个标记上空并居中:回到与初始一致的高度与北朝上方向,左键拖拽手感不变
|
||||||
@@ -247,8 +307,10 @@ export class CesiumGlobe {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (this._lastFly && now - this._lastFly < 400) return; // 双击/连点去抖,避免重复飞
|
if (this._lastFly && now - this._lastFly < 400) return; // 双击/连点去抖,避免重复飞
|
||||||
this._lastFly = now;
|
this._lastFly = now;
|
||||||
this.viewer.camera.flyTo({
|
const camera = this.viewer.camera;
|
||||||
destination: Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat, FLY_TO_HEIGHT),
|
const height = camera.positionCartographic.height; // 保持当前缩放,平移过去
|
||||||
|
camera.flyTo({
|
||||||
|
destination: Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat, height),
|
||||||
orientation: {
|
orientation: {
|
||||||
heading: 0,
|
heading: 0,
|
||||||
pitch: Cesium.Math.toRadians(-90),
|
pitch: Cesium.Math.toRadians(-90),
|
||||||
@@ -275,7 +337,8 @@ export class CesiumGlobe {
|
|||||||
const areaKm2 = isPoly ? sphericalAreaKm2(polys) : 0;
|
const areaKm2 = isPoly ? sphericalAreaKm2(polys) : 0;
|
||||||
|
|
||||||
// 国家名(优先中文),落在 NATURAL EARTH 提供的标注点上,字号按国土面积分级
|
// 国家名(优先中文),落在 NATURAL EARTH 提供的标注点上,字号按国土面积分级
|
||||||
const cname = props.NAME_ZH || props.NAME;
|
let cname = props.NAME_ZH || props.NAME;
|
||||||
|
if (cname === '中华民国') cname = '中国台湾';
|
||||||
if (cname && props.LABEL_X != null && props.LABEL_Y != null) {
|
if (cname && props.LABEL_X != null && props.LABEL_Y != null) {
|
||||||
const fontSize = areaKm2 > 5e6 ? 30 : areaKm2 > 1.5e6 ? 19 : areaKm2 > 3e5 ? 15 : 12;
|
const fontSize = areaKm2 > 5e6 ? 30 : areaKm2 > 1.5e6 ? 19 : areaKm2 > 3e5 ? 15 : 12;
|
||||||
this.viewer.entities.add({
|
this.viewer.entities.add({
|
||||||
@@ -301,7 +364,7 @@ export class CesiumGlobe {
|
|||||||
for (const [lng, lat] of ring) flat.push(lng, lat);
|
for (const [lng, lat] of ring) flat.push(lng, lat);
|
||||||
const positions = Cesium.Cartesian3.fromDegreesArray(flat);
|
const positions = Cesium.Cartesian3.fromDegreesArray(flat);
|
||||||
// 行政区划:一根黑线
|
// 行政区划:一根黑线
|
||||||
lines.push(new Cesium.GeometryInstance({ geometry: new Cesium.GroundPolylineGeometry({ positions, width: 2.0 }) }));
|
lines.push(new Cesium.GeometryInstance({ geometry: new Cesium.GroundPolylineGeometry({ positions, width: 2.6 }) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -380,12 +443,10 @@ export class CesiumGlobe {
|
|||||||
setSelected(index) {
|
setSelected(index) {
|
||||||
this.selected = index;
|
this.selected = index;
|
||||||
if (!this.viewer) return;
|
if (!this.viewer) return;
|
||||||
this.markers.forEach(({ entity, bg, index: i }) => {
|
this.markers.forEach((m) => {
|
||||||
const on = i === index;
|
const on = m.index === index;
|
||||||
entity.point.outlineWidth = on ? 3 : 1; // 选中加粗黑圈,不放大
|
if (m.el) m.el.style.backgroundImage = `url("${on ? m.selectedUrl : m.normalUrl}")`;
|
||||||
if (bg) bg.show = on;
|
|
||||||
});
|
});
|
||||||
this.viewer.scene.requestRender();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setVisible(visible) {
|
setVisible(visible) {
|
||||||
|
|||||||
+77
-31
@@ -1,6 +1,6 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { ENTRIES } from '../data/entries.js';
|
import { ENTRIES } from '../data/entries.js';
|
||||||
import { PAGE_W, PAGE_H, HOLE_U, getPageTextures } from '../textures/pages.js';
|
import { PAGE_W, PAGE_H, HOLE_U, acquirePageTextures, releasePageTextures, getPlaceholderTextures, makeTwoSidedMaterial } from '../textures/pages.js';
|
||||||
import { clamp, easeOutCubic } from '../utils/easing.js';
|
import { clamp, easeOutCubic } from '../utils/easing.js';
|
||||||
|
|
||||||
const RING_R = 0.085;
|
const RING_R = 0.085;
|
||||||
@@ -280,6 +280,8 @@ export class Binder {
|
|||||||
|
|
||||||
this.suedeTex = makeSuedeTexture();
|
this.suedeTex = makeSuedeTexture();
|
||||||
this.suedeNormalTex = makeSuedeNormalMap();
|
this.suedeNormalTex = makeSuedeNormalMap();
|
||||||
|
this.placeholder = getPlaceholderTextures();
|
||||||
|
this._textured = new Set();
|
||||||
this._buildCovers();
|
this._buildCovers();
|
||||||
this._buildHingePlate();
|
this._buildHingePlate();
|
||||||
this._buildRings();
|
this._buildRings();
|
||||||
@@ -385,26 +387,17 @@ export class Binder {
|
|||||||
}
|
}
|
||||||
pa.needsUpdate = true;
|
pa.needsUpdate = true;
|
||||||
const base = Float32Array.from(geo.attributes.position.array);
|
const base = Float32Array.from(geo.attributes.position.array);
|
||||||
const { front, back } = getPageTextures(entry);
|
|
||||||
|
|
||||||
const frontMat = new THREE.MeshStandardMaterial({
|
// 单 mesh + 双面材质:正面登记卡、背面素描(gl_FrontFacing 自动选择)
|
||||||
map: front, roughness: 0.88, metalness: 0.0, side: THREE.FrontSide,
|
// 活页孔用 alphaTest 直接丢弃透明像素(不启用 transparent,避免透明+双面拆两次 draw call)
|
||||||
transparent: true, alphaTest: 0.5,
|
const mat = makeTwoSidedMaterial(this.placeholder.front, this.placeholder.back, {
|
||||||
|
alphaTest: 0.5,
|
||||||
});
|
});
|
||||||
const backMat = new THREE.MeshStandardMaterial({
|
const mesh = new THREE.Mesh(geo, mat);
|
||||||
map: back, roughness: 0.88, metalness: 0.0, side: THREE.BackSide,
|
mesh.castShadow = false; // 由 _restack 依据是否为顶层页控制
|
||||||
transparent: true, alphaTest: 0.5,
|
|
||||||
});
|
|
||||||
|
|
||||||
const frontMesh = new THREE.Mesh(geo, frontMat);
|
|
||||||
frontMesh.position.z = 0.0008;
|
|
||||||
frontMesh.castShadow = true;
|
|
||||||
const backMesh = new THREE.Mesh(geo, backMat);
|
|
||||||
backMesh.position.z = -0.0008;
|
|
||||||
backMesh.castShadow = false;
|
|
||||||
|
|
||||||
const g = new THREE.Group();
|
const g = new THREE.Group();
|
||||||
g.add(frontMesh, backMesh);
|
g.add(mesh);
|
||||||
g.userData.index = i;
|
g.userData.index = i;
|
||||||
g.userData.entry = entry;
|
g.userData.entry = entry;
|
||||||
|
|
||||||
@@ -412,8 +405,9 @@ export class Binder {
|
|||||||
group: g,
|
group: g,
|
||||||
geometry: geo,
|
geometry: geo,
|
||||||
base,
|
base,
|
||||||
frontMat,
|
material: mat,
|
||||||
backMat,
|
mesh,
|
||||||
|
isPlaceholder: true,
|
||||||
angle: 0,
|
angle: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
z: 0,
|
z: 0,
|
||||||
@@ -442,10 +436,45 @@ export class Binder {
|
|||||||
p.group.position.x = 0;
|
p.group.position.x = 0;
|
||||||
// 下层页略暗
|
// 下层页略暗
|
||||||
const dim = 1 - Math.min(depth * 0.06, 0.22);
|
const dim = 1 - Math.min(depth * 0.06, 0.22);
|
||||||
p.frontMat.color.setScalar(dim);
|
p.material.color.setScalar(dim);
|
||||||
p.backMat.color.setScalar(dim);
|
// 只让顶层(当前右页 + 左侧页)投影,栈内被遮挡页不投影,省阴影 pass
|
||||||
|
p.mesh.castShadow = (j === this.current || j === this.current - 1);
|
||||||
});
|
});
|
||||||
this._updateCurvature();
|
this._updateCurvature();
|
||||||
|
this._ensureTextures();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 需要真实纹理的窗口:当前页 ±1(左右可见页 + 翻页缓冲)
|
||||||
|
_pageWindow() {
|
||||||
|
const len = this.pages.length;
|
||||||
|
const win = new Set();
|
||||||
|
for (let d = -1; d <= 1; d++) {
|
||||||
|
const i = this.current + d;
|
||||||
|
if (i >= 0 && i < len) win.add(i);
|
||||||
|
}
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 懒加载:窗口内页生成真实纹理,窗口外释放回占位纹理
|
||||||
|
_ensureTextures() {
|
||||||
|
const win = this._pageWindow();
|
||||||
|
const prev = this._textured;
|
||||||
|
for (const i of win) {
|
||||||
|
if (prev.has(i)) continue;
|
||||||
|
const p = this.pages[i];
|
||||||
|
const { front, back } = acquirePageTextures(ENTRIES[i]);
|
||||||
|
p.material.setPageMaps(front, back);
|
||||||
|
p.isPlaceholder = false;
|
||||||
|
prev.add(i);
|
||||||
|
}
|
||||||
|
for (const i of [...prev]) {
|
||||||
|
if (win.has(i)) continue;
|
||||||
|
const p = this.pages[i];
|
||||||
|
releasePageTextures(ENTRIES[i]);
|
||||||
|
p.material.setPageMaps(this.placeholder.front, this.placeholder.back);
|
||||||
|
p.isPlaceholder = true;
|
||||||
|
prev.delete(i);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateCurvature(index = null) {
|
_updateCurvature(index = null) {
|
||||||
@@ -483,20 +512,36 @@ export class Binder {
|
|||||||
canFlipForward() { return !this.flip; }
|
canFlipForward() { return !this.flip; }
|
||||||
canFlipBackward() { return !this.flip && this.current > 0; }
|
canFlipBackward() { return !this.flip && this.current > 0; }
|
||||||
|
|
||||||
|
_finalizeFlip() {
|
||||||
|
// 立即把正在翻的页收到目标位置,好让下一页马上开始翻
|
||||||
|
if (!this.flip) return;
|
||||||
|
const f = this.flip;
|
||||||
|
const p = this.pages[f.index];
|
||||||
|
p.angle = f.to;
|
||||||
|
p.group.rotation.y = f.to;
|
||||||
|
p.z = 0;
|
||||||
|
p.group.position.z = 0;
|
||||||
|
if (f.to === -Math.PI) {
|
||||||
|
this.current = (this.current + 1) % this.pages.length;
|
||||||
|
} else {
|
||||||
|
this.current -= 1;
|
||||||
|
}
|
||||||
|
this.flip = null;
|
||||||
|
this._restack(false);
|
||||||
|
}
|
||||||
|
|
||||||
flipForward() {
|
flipForward() {
|
||||||
|
if (this.flip) this._finalizeFlip(); // 前一页没翻完也能继续翻
|
||||||
if (!this.canFlipForward()) return;
|
if (!this.canFlipForward()) return;
|
||||||
this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 0.6 };
|
this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 0.6 };
|
||||||
const p = this.pages[this.current];
|
|
||||||
p.z = 0.006; p.targetZ = 0.006; p.group.position.z = 0.006;
|
|
||||||
// 优先更新底部导航与信息卡(翻页开始即切换目标页)
|
// 优先更新底部导航与信息卡(翻页开始即切换目标页)
|
||||||
if (this.onChange) this.onChange((this.current + 1) % this.pages.length);
|
if (this.onChange) this.onChange((this.current + 1) % this.pages.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
flipBackward() {
|
flipBackward() {
|
||||||
|
if (this.flip) this._finalizeFlip();
|
||||||
if (!this.canFlipBackward()) return;
|
if (!this.canFlipBackward()) return;
|
||||||
this.flip = { index: this.current - 1, from: -Math.PI, to: 0, t: 0, duration: 0.6 };
|
this.flip = { index: this.current - 1, from: -Math.PI, to: 0, t: 0, duration: 0.6 };
|
||||||
const p = this.pages[this.current - 1];
|
|
||||||
p.z = 0.006; p.targetZ = 0.006; p.group.position.z = 0.006;
|
|
||||||
// 优先更新底部导航与信息卡
|
// 优先更新底部导航与信息卡
|
||||||
if (this.onChange) this.onChange(this.current - 1);
|
if (this.onChange) this.onChange(this.current - 1);
|
||||||
}
|
}
|
||||||
@@ -512,6 +557,9 @@ export class Binder {
|
|||||||
const p = this.pages[f.index];
|
const p = this.pages[f.index];
|
||||||
p.angle = angle;
|
p.angle = angle;
|
||||||
p.group.rotation.y = angle;
|
p.group.rotation.y = angle;
|
||||||
|
// 翻页时 z 随进度同步起伏(中途 0.006,起止为 0),不瞬跳
|
||||||
|
p.z = 0.006 * Math.sin(Math.PI * k);
|
||||||
|
p.group.position.z = p.z;
|
||||||
this._updateCurvature(f.index);
|
this._updateCurvature(f.index);
|
||||||
|
|
||||||
if (k >= 1) {
|
if (k >= 1) {
|
||||||
@@ -524,9 +572,10 @@ export class Binder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 页面堆叠进退动画:z 平滑过渡到目标
|
// 页面堆叠进退动画:z 平滑过渡到目标(翻页中的那一页由上面进度直接驱动)
|
||||||
const lerpK = Math.min(1, dt * 14);
|
const lerpK = Math.min(1, dt * 14);
|
||||||
this.pages.forEach((p) => {
|
this.pages.forEach((p, i) => {
|
||||||
|
if (this.flip && i === this.flip.index) return;
|
||||||
p.z += (p.targetZ - p.z) * lerpK;
|
p.z += (p.targetZ - p.z) * lerpK;
|
||||||
p.group.position.z = p.z;
|
p.group.position.z = p.z;
|
||||||
});
|
});
|
||||||
@@ -535,10 +584,7 @@ export class Binder {
|
|||||||
dispose() {
|
dispose() {
|
||||||
this.pages.forEach((p) => {
|
this.pages.forEach((p) => {
|
||||||
p.geometry.dispose();
|
p.geometry.dispose();
|
||||||
p.frontMat.map.dispose();
|
p.material.dispose();
|
||||||
p.frontMat.dispose();
|
|
||||||
p.backMat.map.dispose();
|
|
||||||
p.backMat.dispose();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-15
@@ -1,6 +1,6 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { ENTRIES } from '../data/entries.js';
|
import { ENTRIES } from '../data/entries.js';
|
||||||
import { getPageTextures } from '../textures/pages.js';
|
import { acquirePageTextures, releasePageTextures, getPlaceholderTextures, makeTwoSidedMaterial } from '../textures/pages.js';
|
||||||
import { makeTextSprite } from '../utils/labels.js';
|
import { makeTextSprite } from '../utils/labels.js';
|
||||||
import { clamp, easeInOutCubic } from '../utils/easing.js';
|
import { clamp, easeInOutCubic } from '../utils/easing.js';
|
||||||
|
|
||||||
@@ -22,6 +22,11 @@ export class Timeline {
|
|||||||
this.group = new THREE.Group();
|
this.group = new THREE.Group();
|
||||||
this.cards = [];
|
this.cards = [];
|
||||||
this.selected = -1;
|
this.selected = -1;
|
||||||
|
this.placeholder = getPlaceholderTextures();
|
||||||
|
this._textured = new Set();
|
||||||
|
this._visRange = null;
|
||||||
|
this._ready = false;
|
||||||
|
this._pending = [];
|
||||||
|
|
||||||
this._buildLine();
|
this._buildLine();
|
||||||
this._buildCards();
|
this._buildCards();
|
||||||
@@ -54,27 +59,21 @@ export class Timeline {
|
|||||||
|
|
||||||
ENTRIES.forEach((entry, i) => {
|
ENTRIES.forEach((entry, i) => {
|
||||||
const x = cardX(i, n);
|
const x = cardX(i, n);
|
||||||
const { front, back } = getPageTextures(entry);
|
|
||||||
|
|
||||||
// 每张卡片独立几何(带分段,便于翻面时弯曲出纸感)
|
// 每张卡片独立几何(带分段,便于翻面时弯曲出纸感)
|
||||||
const cardGeo = new THREE.PlaneGeometry(0.4, 0.565, 24, 6);
|
const cardGeo = new THREE.PlaneGeometry(0.4, 0.565, 24, 6);
|
||||||
const base = Float32Array.from(cardGeo.attributes.position.array);
|
const base = Float32Array.from(cardGeo.attributes.position.array);
|
||||||
|
|
||||||
// 翻转容器:正面登记卡 + 背面素描
|
// 翻转容器:单 mesh + 双面材质(翻面时 gl_FrontFacing 自动切换正/背)
|
||||||
const flipGroup = new THREE.Group();
|
const flipGroup = new THREE.Group();
|
||||||
flipGroup.position.set(x, 0, 0);
|
flipGroup.position.set(x, 0, 0);
|
||||||
|
|
||||||
const frontMat = new THREE.MeshStandardMaterial({ map: front, roughness: 0.85, metalness: 0.0, side: THREE.FrontSide });
|
const mat = makeTwoSidedMaterial(this.placeholder.front, this.placeholder.back, { roughness: 0.86 });
|
||||||
const frontMesh = new THREE.Mesh(cardGeo, frontMat);
|
const cardMesh = new THREE.Mesh(cardGeo, mat);
|
||||||
frontMesh.userData = { view: 'timeline', index: i };
|
cardMesh.userData = { view: 'timeline', index: i };
|
||||||
frontMesh.castShadow = true;
|
cardMesh.castShadow = true;
|
||||||
|
|
||||||
const backMat = new THREE.MeshStandardMaterial({ map: back, roughness: 0.88, metalness: 0.0, side: THREE.BackSide });
|
flipGroup.add(cardMesh);
|
||||||
const backMesh = new THREE.Mesh(cardGeo, backMat);
|
|
||||||
backMesh.position.z = -0.001;
|
|
||||||
backMesh.userData = { view: 'timeline', index: i };
|
|
||||||
|
|
||||||
flipGroup.add(frontMesh, backMesh);
|
|
||||||
|
|
||||||
// 活页上方:主题(常显,随选中上浮,超宽居中换行)
|
// 活页上方:主题(常显,随选中上浮,超宽居中换行)
|
||||||
const name = makeTextSprite(entry.name, { fontSize: 17, color: '#e8d9b4', scale: 0.12, maxWidth: 0.9 });
|
const name = makeTextSprite(entry.name, { fontSize: 17, color: '#e8d9b4', scale: 0.12, maxWidth: 0.9 });
|
||||||
@@ -85,7 +84,7 @@ export class Timeline {
|
|||||||
time.position.set(x, TIME_Y, 0);
|
time.position.set(x, TIME_Y, 0);
|
||||||
|
|
||||||
this.group.add(flipGroup, name, time);
|
this.group.add(flipGroup, name, time);
|
||||||
this.cards.push({ card: flipGroup, front: frontMesh, back: backMesh, geo: cardGeo, base, name, nameBaseY: NAME_Y, index: i, flipped: false, flipAnim: null });
|
this.cards.push({ card: flipGroup, mesh: cardMesh, material: mat, geo: cardGeo, base, name, nameBaseY: NAME_Y, index: i, flipped: false, flipAnim: null, isPlaceholder: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,13 +97,86 @@ export class Timeline {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 首次进入时间轴视图时由 App 调用,此后由 App 每帧按可视区域驱动窗口
|
||||||
|
refreshTextures() {
|
||||||
|
this._ready = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// App 每帧根据可视区域调用:minI/maxI 为可见卡片索引范围(可为小数)
|
||||||
|
setVisibleRange(minI, maxI) {
|
||||||
|
const len = this.cards.length;
|
||||||
|
const PAD = 4; // 缓冲:可视范围外多生成几页,拖拽时提前就绪
|
||||||
|
const lo = Math.max(0, Math.floor(minI) - PAD);
|
||||||
|
const hi = Math.min(len - 1, Math.ceil(maxI) + PAD);
|
||||||
|
if (this._visRange && this._visRange.min === lo && this._visRange.max === hi) return;
|
||||||
|
this._visRange = { min: lo, max: hi };
|
||||||
|
this._refreshTextures();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 世界坐标可视范围 → 索引范围(内部减去 group 偏移;卡片组内坐标 = cardX(i))
|
||||||
|
setVisibleWorldRange(minX, maxX) {
|
||||||
|
const n = this.cards.length;
|
||||||
|
const offset = this.group.position.x;
|
||||||
|
const lo = (minX - offset) / CARD_GAP + (n - 1) / 2;
|
||||||
|
const hi = (maxX - offset) / CARD_GAP + (n - 1) / 2;
|
||||||
|
this.setVisibleRange(lo, hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
_window() {
|
||||||
|
const win = new Set();
|
||||||
|
if (!this._visRange) return win;
|
||||||
|
for (let i = this._visRange.min; i <= this._visRange.max; i++) win.add(i);
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
_refreshTextures() {
|
||||||
|
if (!this._ready) return;
|
||||||
|
const win = this._window();
|
||||||
|
|
||||||
|
// 离开窗口:释放真实纹理,换回占位
|
||||||
|
for (const i of [...this._textured]) {
|
||||||
|
if (win.has(i)) continue;
|
||||||
|
const c = this.cards[i];
|
||||||
|
releasePageTextures(ENTRIES[i]);
|
||||||
|
c.material.setPageMaps(this.placeholder.front, this.placeholder.back);
|
||||||
|
c.isPlaceholder = true;
|
||||||
|
this._textured.delete(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进入窗口:分帧异步生成,避免一次性生成 25 页阻塞主线程
|
||||||
|
this._pending = [...win].filter((i) => !this._textured.has(i) && this.cards[i].isPlaceholder);
|
||||||
|
this.drainPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每帧由 App._animate 驱动,逐批生成窗口内纹理(避免阻塞)
|
||||||
|
drainPending() {
|
||||||
|
if (!this._pending || !this._pending.length) return;
|
||||||
|
const budget = 2; // 每帧最多生成 2 页
|
||||||
|
for (let k = 0; k < budget && this._pending.length; k++) {
|
||||||
|
const i = this._pending.shift();
|
||||||
|
const c = this.cards[i];
|
||||||
|
if (!c || this._textured.has(i)) continue;
|
||||||
|
const { front, back } = acquirePageTextures(ENTRIES[i]);
|
||||||
|
c.material.setPageMaps(front, back);
|
||||||
|
c.isPlaceholder = false;
|
||||||
|
this._textured.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 返回某张卡片在时间轴组内的横向坐标(用于点击后居中)
|
// 返回某张卡片在时间轴组内的横向坐标(用于点击后居中)
|
||||||
getCardX(index) {
|
getCardX(index) {
|
||||||
return cardX(index, ENTRIES.length);
|
return cardX(index, ENTRIES.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点击翻转:每次点击切换正/反面,带旋转动画
|
// 点击翻转:每次点击切换正/反面;选中另一张时,其它翻到背面的卡片先翻回正面
|
||||||
flip(index) {
|
flip(index) {
|
||||||
|
this.cards.forEach((o) => {
|
||||||
|
if (o.index !== index && o.flipped) {
|
||||||
|
o.flipped = false;
|
||||||
|
o.flipAnim = { from: o.card.rotation.y, to: 0, t: 0, dur: 0.6 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const c = this.cards[index];
|
const c = this.cards[index];
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
c.flipped = !c.flipped;
|
c.flipped = !c.flipped;
|
||||||
|
|||||||
+16
-1
@@ -37,6 +37,20 @@ html, body {
|
|||||||
/* 隐藏左下角全部归属信息(Cesium ion logo + 影像文字) */
|
/* 隐藏左下角全部归属信息(Cesium ion logo + 影像文字) */
|
||||||
#cesium-container .cesium-widget-credits { display: none !important; }
|
#cesium-container .cesium-widget-credits { display: none !important; }
|
||||||
|
|
||||||
|
/* 标记便签 DOM 覆盖层(在 Cesium 画布之上) */
|
||||||
|
#marker-layer {
|
||||||
|
position: absolute; inset: 0; z-index: 20;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.marker-note {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: auto;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 100% 100%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* 气氛遮罩 */
|
/* 气氛遮罩 */
|
||||||
.vignette {
|
.vignette {
|
||||||
position: absolute; inset: 0; pointer-events: none;
|
position: absolute; inset: 0; pointer-events: none;
|
||||||
@@ -76,7 +90,7 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 顶部标题 */
|
/* 顶部标题 */
|
||||||
.hud { position: absolute; z-index: 5; }
|
.hud { position: absolute; z-index: 40; }
|
||||||
.top-left { top: 22px; left: 26px; }
|
.top-left { top: 22px; left: 26px; }
|
||||||
.title {
|
.title {
|
||||||
font-size: clamp(20px, 2.4vw, 34px);
|
font-size: clamp(20px, 2.4vw, 34px);
|
||||||
@@ -137,6 +151,7 @@ html, body {
|
|||||||
/* 登记卡详情 */
|
/* 登记卡详情 */
|
||||||
.detail-card {
|
.detail-card {
|
||||||
right: 24px; bottom: 22px; width: min(320px, 82vw);
|
right: 24px; bottom: 22px; width: min(320px, 82vw);
|
||||||
|
z-index: 40; /* 始终置顶,盖在标记便签层之上 */
|
||||||
padding: 18px 20px; border-radius: 4px;
|
padding: 18px 20px; border-radius: 4px;
|
||||||
background: linear-gradient(180deg, rgba(232,216,182,0.96), rgba(214,196,156,0.96));
|
background: linear-gradient(180deg, rgba(232,216,182,0.96), rgba(214,196,156,0.96));
|
||||||
color: var(--ink); box-shadow: 0 18px 50px rgba(0,0,0,0.55), inset 0 0 0 1px rgba(90,61,43,0.4);
|
color: var(--ink); box-shadow: 0 18px 50px rgba(0,0,0,0.55), inset 0 0 0 1px rgba(90,61,43,0.4);
|
||||||
|
|||||||
+123
-14
@@ -7,7 +7,7 @@ export const PAGE_W = 1.6; // 世界单位:页宽
|
|||||||
export const PAGE_H = 2.24; // 世界单位:页高
|
export const PAGE_H = 2.24; // 世界单位:页高
|
||||||
export const TEX_W = 768;
|
export const TEX_W = 768;
|
||||||
export const TEX_H = 1075;
|
export const TEX_H = 1075;
|
||||||
// 输出纹理与绘制分辨率一致,保证文字清晰
|
// 输出纹理与绘制分辨率一致,保证文字清晰(显存靠懒加载 + LRU 控制,不牺牲画质)
|
||||||
export const TEX_OUT_W = 768;
|
export const TEX_OUT_W = 768;
|
||||||
export const TEX_OUT_H = 1075;
|
export const TEX_OUT_H = 1075;
|
||||||
export const HOLE_U = [0.1, 0.26, 0.42, 0.58, 0.74, 0.9]; // 活页孔(自顶部归一化,6孔等距)
|
export const HOLE_U = [0.1, 0.26, 0.42, 0.58, 0.74, 0.9]; // 活页孔(自顶部归一化,6孔等距)
|
||||||
@@ -323,19 +323,128 @@ function downscale(src) {
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成页面的两个纹理
|
// —— 页面纹理池:懒生成 + 引用计数 + LRU 释放 ——
|
||||||
const texCache = new Map();
|
// 启动时只为“当前可见窗口”生成真实纹理,其余页面用共享占位纹理;
|
||||||
export function getPageTextures(entry) {
|
// 浏览中按需生成,离开窗口的纹理自动释放,控制显存占用。
|
||||||
if (texCache.has(entry.id)) return texCache.get(entry.id);
|
|
||||||
const front = new THREE.CanvasTexture(downscale(renderFront(entry)));
|
|
||||||
front.colorSpace = THREE.SRGBColorSpace;
|
|
||||||
front.anisotropy = 8;
|
|
||||||
|
|
||||||
const back = new THREE.CanvasTexture(downscale(renderBack(entry)));
|
const MAX_CACHED_PAGES = 40; // 最多缓存 40 页(80 张)真实纹理
|
||||||
back.colorSpace = THREE.SRGBColorSpace;
|
const texCache = new Map(); // id -> { entry, front, back, refs, lastUsed }
|
||||||
back.anisotropy = 8;
|
let placeholder = null;
|
||||||
|
|
||||||
const pair = { front, back };
|
function makeTexture(canvas) {
|
||||||
texCache.set(entry.id, pair);
|
const tex = new THREE.CanvasTexture(canvas);
|
||||||
return pair;
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
tex.anisotropy = 8; // 保持倾斜视角下的清晰度
|
||||||
|
return tex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空白活页纸占位纹理(全局共享一份)
|
||||||
|
function renderPlaceholder() {
|
||||||
|
const w = TEX_W, h = TEX_H;
|
||||||
|
const { canvas, ctx } = makePaper(w, h, { seed: 42 });
|
||||||
|
drawBorder(ctx, w, h);
|
||||||
|
drawHoles(ctx, w, h);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlaceholderTextures() {
|
||||||
|
if (!placeholder) {
|
||||||
|
const front = makeTexture(downscale(renderPlaceholder()));
|
||||||
|
const back = makeTexture(downscale(renderPlaceholder()));
|
||||||
|
placeholder = { front, back };
|
||||||
|
}
|
||||||
|
return placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePair(entry) {
|
||||||
|
const front = makeTexture(downscale(renderFront(entry)));
|
||||||
|
const back = makeTexture(downscale(renderBack(entry)));
|
||||||
|
return { front, back };
|
||||||
|
}
|
||||||
|
|
||||||
|
function evictIfNeeded() {
|
||||||
|
if (texCache.size <= MAX_CACHED_PAGES) return;
|
||||||
|
const candidates = [...texCache.values()]
|
||||||
|
.filter((r) => r.refs === 0)
|
||||||
|
.sort((a, b) => a.lastUsed - b.lastUsed);
|
||||||
|
while (texCache.size > MAX_CACHED_PAGES && candidates.length) {
|
||||||
|
const r = candidates.shift();
|
||||||
|
r.front.dispose();
|
||||||
|
r.back.dispose();
|
||||||
|
texCache.delete(r.entry.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function acquirePageTextures(entry) {
|
||||||
|
let rec = texCache.get(entry.id);
|
||||||
|
if (!rec) {
|
||||||
|
rec = { entry, front: null, back: null, refs: 0, lastUsed: 0 };
|
||||||
|
texCache.set(entry.id, rec);
|
||||||
|
}
|
||||||
|
rec.refs++;
|
||||||
|
rec.lastUsed = performance.now();
|
||||||
|
if (!rec.front) {
|
||||||
|
const pair = generatePair(entry);
|
||||||
|
rec.front = pair.front;
|
||||||
|
rec.back = pair.back;
|
||||||
|
evictIfNeeded();
|
||||||
|
}
|
||||||
|
return { front: rec.front, back: rec.back };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releasePageTextures(entry) {
|
||||||
|
const rec = texCache.get(entry.id);
|
||||||
|
if (!rec) return;
|
||||||
|
rec.refs = Math.max(0, rec.refs - 1);
|
||||||
|
if (rec.refs === 0) rec.lastUsed = performance.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 双面材质:同一 mesh 用 gl_FrontFacing 在正/反面显示不同纹理,
|
||||||
|
// 替代原先 front/back 两个 mesh,把每页 draw call 减半。
|
||||||
|
export function makeTwoSidedMaterial(front, back, {
|
||||||
|
roughness = 0.88,
|
||||||
|
metalness = 0.0,
|
||||||
|
transparent = false,
|
||||||
|
alphaTest = 0,
|
||||||
|
} = {}) {
|
||||||
|
const mat = new THREE.MeshStandardMaterial({
|
||||||
|
map: front, // 仅用于激活 USE_MAP / UV;实际采样由注入代码接管
|
||||||
|
roughness,
|
||||||
|
metalness,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
transparent,
|
||||||
|
alphaTest,
|
||||||
|
});
|
||||||
|
|
||||||
|
mat.userData.pageMaps = { front, back };
|
||||||
|
let compiled = null;
|
||||||
|
|
||||||
|
mat.onBeforeCompile = (shader) => {
|
||||||
|
compiled = shader;
|
||||||
|
shader.uniforms.frontMap = { value: mat.userData.pageMaps.front };
|
||||||
|
shader.uniforms.backMap = { value: mat.userData.pageMaps.back };
|
||||||
|
shader.fragmentShader = shader.fragmentShader
|
||||||
|
.replace(
|
||||||
|
'#include <common>',
|
||||||
|
'#include <common>\nuniform sampler2D frontMap;\nuniform sampler2D backMap;',
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
'#include <map_fragment>',
|
||||||
|
`#ifdef USE_MAP
|
||||||
|
vec4 _sideColor = gl_FrontFacing ? texture2D( frontMap, vMapUv ) : texture2D( backMap, vMapUv );
|
||||||
|
diffuseColor *= _sideColor;
|
||||||
|
#endif`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
mat.setPageMaps = (f, b) => {
|
||||||
|
mat.userData.pageMaps = { front: f, back: b };
|
||||||
|
mat.map = f;
|
||||||
|
if (compiled) {
|
||||||
|
compiled.uniforms.frontMap.value = f;
|
||||||
|
compiled.uniforms.backMap.value = b;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return mat;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user