三維流體模組API開發-Voxel
[info] 小提示:
程式碼連結:https://doc-3dgdp.colife.org.tw/samplecode/#src/testweb/fluid_voxel/
初始化三維流體模組
三維流體模組需使用WebGL2,注意圖台初始化時要在參數中設定 requestWebGL2: true
。
index.html
:
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title></title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--引入js-->
<script src="https://sample.pilotgaea.com.tw/demo/index/src/PGWebJS/13.0/PGWeb3D.min.js"></script>
<!--引入css-->
<link
href="https://sample.pilotgaea.com.tw/demo/index/src/PGWebJS/13.0/css/PGWeb3D.css"
rel="stylesheet"
type="text/css"
/>
<link
href="https://sample.pilotgaea.com.tw/demo/index/css/samplecode.css"
rel="stylesheet"
type="text/css"
/>
<style>
.hidden {
display: none !important;
}
</style>
</head>
<body>
<aside class="control" style="position: absolute; z-index: 1">
<details open>
<summary>資料來源</summary>
<fieldset>
<label for="data0">速度</label>
<input id="data0" type="radio" name="data" value="voxelA" checked />
<label for="data1">溫度</label>
<input id="data1" type="radio" name="data" value="voxelB" />
<label for="data2">氣壓</label>
<input id="data2" type="radio" name="data" value="voxelC" />
</fieldset>
<section id="legend"></section>
</details>
<details open>
<summary>視覺化</summary>
<fieldset>
<label>顯示體積</label>
<input id="showVoxel" type="radio" name="visual" checked />
<label>顯示裁切面</label>
<input id="showPlane" type="radio" name="visual" />
</fieldset>
<fieldset id="voxelPanel">
<hr />
<label>透明度</label>
<input
id="opacity"
type="range"
value="1"
min="0"
max="1"
step="0.01"
/>
<label>點模式</label>
<input id="pointMode" type="checkbox" />
<label>顯示裁切體</label>
<input id="slice" type="checkbox" />
<label>顯示模型</label>
<input id="modelset_enable" type="checkbox" checked />
</fieldset>
</details>
<details id="planePanel" class="hidden" open>
<summary>裁切面</summary>
<section>
<button id="addPlane">+ 增加裁切面</button>
<div id="planeList"></div>
</section>
</details>
<details id="slicePanel" class="hidden" open>
<summary>裁切體</summary>
<section>
<button id="addSlice">+ 增加裁切體</button>
<div id="sliceList"></div>
</section>
</details>
</aside>
<div
id="MyMap"
class="map"
style="width: 100%; height: 100%; position: absolute; top: 0; left: 0"
></div>
<script src="js/components.js" type="module"></script>
<script src="js/main.js" type="module"></script>
</body>
</html>
main.js
:
// 組件實作見 components.js
import {
ObjectFullControl,
ObjectAAControl,
Legend,
render,
} from "./components.js";
// Voxel模組需要啟用 WebGL2 才能使用
const terrainview = new ov.TerrainView("MyMap", { requestWebGL2: true });
var msl = null;
// Voxel資料列表
const infoList = {
voxelA: {
title: "速度資料",
fieldName: "速度",
fieldUnit: "m/s",
dataSrc: "./data/LungShanRH_202301010000.csv",
colorSet: ["#2b79ba00", "#abdda440", "#ffffbf80", "#fdae61bf", "#d7191c"],
colorPos: [0.0, 0.25, 0.5, 0.75, 1.0],
},
voxelB: {
title: "溫度資料",
fieldName: "溫度",
fieldUnit: "°C",
dataSrc: "./data/LungShanTC_202301010000.csv",
colorSet: ["#f6dfc700", "#e5a89b40", "#dd837880", "#bb4a49bf", "#b72917"],
colorPos: [0.0, 0.25, 0.5, 0.75, 1.0],
},
voxelC: {
title: "氣壓資料",
fieldName: "氣壓",
fieldUnit: "巴",
dataSrc: "./data/LungShanRH_202301080010.csv",
colorSet: ["#4d589700", "#9866ac40", "#c998c380", "#f4c5e8bf", "#fcefed"],
colorPos: [0.0, 0.25, 0.5, 0.75, 1.0],
},
};
/** 現在顯示的Voxel的資訊 */
let current = null;
// 在使用其他圖層前必須要先讀入地形
terrainview.openTerrain(
{
url: 'https://data-3dgdp.colife.org.tw/Sample_src/PGWebJS/13.0/oviewRP.ashx', // 或您自己的O'View MapServer服務
identifier: "範例地形圖",
callback: openCallback,
}
);
/** 地形讀入後的回呼 */
function openCallback() {
// 設定底圖
terrainview.setBaseLayer({
url: "BING_MAP",
identifier: "IMAGE",
});
//設定初始位置
let initialPos = new GeoPoint(
121.2347878442796,
23.427553934089445,
465850.0013822634
);
let initialV = new Geo3DPoint(0, 0, -1);
let initialUp = new Geo3DPoint(0, 1, 0);
let initialCamera = new ov.Camera(initialPos, initialV, initialUp);
terrainview.gotoCamera(initialCamera, false);
terrainview.addModelSetLayer({
url: "http://127.0.0.1:8080",
identifier: "產權大樓",
callback: function (success, layer) {
if (success) {
msl = layer;
msl.setScale(0.5);
msl.setOffset(14710, 156460, -1930);
msl.setRotate(96);
} else {
console.log("載入不成功");
}
},
urlTemplate: "https://sample.pilotgaea.com.tw/Oview.aspx?{URL}",
});
// 初始化場景
initScene();
}
/** 初始化場景 */
async function initScene() {
// 先從 TerrainView 取得 Voxel 模組
const voxel = terrainview.getModule("voxel");
// 用 Voxel 模組載入資料
const keys = Object.keys(infoList);
await Promise.allSettled(
keys.map((key) => createVoxelEntity(voxel, infoList[key]))
);
changeVoxel();
current.entity.goto(); // 跳到 Voxel 處
setupEventListeners(voxel);
}
/** 設定使用者輸入事件 */
function setupEventListeners(voxel) {
const dataRadios = document.querySelectorAll('input[name="data"]');
const voxelRadio = document.getElementById("showVoxel");
const planeRadio = document.getElementById("showPlane");
const opacityRange = document.getElementById("opacity");
const addPlaneButton = document.getElementById("addPlane");
const addSliceButton = document.getElementById("addSlice");
// 切換資料來源
dataRadios.forEach((radio) => radio.addEventListener("click", changeVoxel));
// 切換顯示模式
voxelRadio.addEventListener("change", (e) =>
toggleVisualization(e.currentTarget.checked)
);
planeRadio.addEventListener("change", (e) =>
toggleVisualization(!e.currentTarget.checked)
);
// 透明度
opacityRange.addEventListener(
"input",
() => (voxel.opacity = opacityRange.valueAsNumber)
);
// 增加裁切面/體
addPlaneButton.addEventListener("click", () =>
current.planeList.push(
addPlane(current.entity, ObjectFullControl, {
point: new GeoPoint(0, 0, 3),
tilt: 90,
orientation: 0,
})
)
);
addSliceButton.addEventListener("click", () =>
current.sliceList.push(
addSlice(
current.entity,
ObjectFullControl,
{},
{ point: new GeoPoint(0, 0, 3), tilt: 90, orientation: 0 }
)
)
);
}
async function createVoxelEntity(voxel, info) {
const { title, fieldName, fieldUnit, dataSrc, colorSet, colorPos } = info;
// 抓資料
const url = new URL(dataSrc, window.location.href);
const { data, min, max } = await fetchVoxelDataFromCSV(url);
info.min = min;
info.max = max;
const entity = voxel.addVoxelEntity({
data,
width: 50,
height: 20,
depth: 3,
colorSet: colorSet.map((c) => new ov.Color(c)),
colorPos,
});
// 設定點擊時的顯示UI
entity.setOnClickEntity((value, pos) => {
new ov.Widget.Attributes({
title,
view: terrainview,
style: { top: `${pos.y}px`, left: `${pos.x}px` },
content: { [fieldName]: `${value.toFixed(4)} ${fieldUnit}` },
});
});
// 創建可以控制裁切面/裁切體的組件
info.planeList = [];
info.sliceList = [];
const planeCon = addPlane(entity, ObjectFullControl, {
point: new GeoPoint(0, 0, 3),
tilt: 70,
orientation: 45,
});
const sliceXCon = addSlice(
entity,
ObjectAAControl,
{ axis: "x", min: "-80", max: "80" },
{ point: new GeoPoint(0, 0, 3), tilt: 90, orientation: 270 }
);
const sliceYCon = addSlice(
entity,
ObjectAAControl,
{ axis: "y", min: "-30", max: "30" },
{ point: new GeoPoint(0, 30, 3), tilt: 90, orientation: 0 }
);
const sliceZCon = addSlice(
entity,
ObjectAAControl,
{ axis: "z", min: "-0.1", max: "7" },
{ point: new GeoPoint(0, 0, 7), tilt: 0, orientation: 0 }
);
// 先不顯示
planeCon.plane.show = false;
sliceXCon.slice.show = false;
sliceYCon.slice.show = false;
sliceZCon.slice.show = false;
info.planeList.push(planeCon);
info.sliceList.push(sliceXCon);
info.sliceList.push(sliceYCon);
info.sliceList.push(sliceZCon);
// 設定點模式和裁切體顯示的輸入事件
const pointMode = document.getElementById("pointMode");
const sliceBox = document.getElementById("slice");
pointMode.addEventListener("change", (e) =>
entity.update({ pointMode: e.target.checked })
);
sliceBox.addEventListener("change", (e) => toggleSlice(e.target.checked));
//模型開關
const mlsEnableButton = document.getElementById("modelset_enable");
mlsEnableButton.addEventListener("change", () => {
msl.show = mlsEnableButton.checked;
});
info.entity = entity;
}
function addPlane(entity, controlComponent, parameter) {
const plane = entity.addSectionPlane(parameter);
const control = controlComponent({ obj: plane, parameter });
document.getElementById("planeList").append(control);
return { control, plane };
}
function addSlice(entity, controlComponent, controlProps, parameter) {
const slice = entity.addSlice(parameter);
if (!slice) return; // 到達裁切上限
const control = controlComponent({ obj: slice, parameter, ...controlProps });
document.getElementById("sliceList").append(control);
return { control, slice };
}
/** 更換顯示的Voxel */
function changeVoxel() {
const checked = document.querySelector('input[name="data"]:checked').value;
for (let key in infoList) {
toggleVoxel(infoList[key], false);
}
current = infoList[checked];
toggleVoxel(current, true);
// 重置選項
document.getElementById("showVoxel").checked = true;
document.getElementById("showPlane").checked = false;
toggleVisualization(true);
document.getElementById("slice").checked = false;
toggleSlice(false);
// 更新圖例
render(Legend(current), document.getElementById("legend"));
}
function toggleVoxel(info, show) {
info.entity.show = show;
info.entity.sectionPlanes.forEach((p) => (p.show = false));
info.planeList.forEach((c) => c.control.classList.toggle("hidden", !show));
info.sliceList.forEach((c) => c.control.classList.toggle("hidden", !show));
}
function toggleVisualization(showEntity) {
// 體積和裁切面其實可以同時顯示,這裡為了展示擇一顯示
current.entity.show = showEntity;
current.entity.sectionPlanes.forEach((p) => (p.show = !showEntity));
document.getElementById("voxelPanel").classList.toggle("hidden", !showEntity);
document.getElementById("planePanel").classList.toggle("hidden", showEntity);
if (!showEntity) {
document.getElementById("slice").checked = false;
toggleSlice(false);
}
}
function toggleSlice(show) {
current.entity.slices.forEach((p) => (p.show = show));
document.getElementById("slicePanel").classList.toggle("hidden", !show);
}
async function fetchVoxelDataFromCSV(url) {
const response = await fetch(url);
const text = await response.text();
let min = Infinity;
let max = -Infinity;
// 解析CSV
let data = text
.split("\r\n")
.map((line) => line.split(",").map(parseFloat))
.filter((row) => {
for (let col of row) {
if (isNaN(col)) {
return false;
}
}
// 順便撈一下範圍
min = Math.min(min, row[3]);
max = Math.max(max, row[3]);
return true;
});
return { data, min, max };
}
components.js
:
// 使用到的組件,這裡是直接原生寫的
// 用框架的話可以參考註解的JSX
export function ObjectFullControl(props) {
const { obj, parameter } = props;
const point = parameter.point.Clone();
const xProps = {
obj, name: "X",
attr: { type: "range", value: point.x.toString(), min: "-50", max: "50", step: "0.1" },
update: (value) => { point.x = value; return { point: point.Clone() }; }
}
const yProps = {
obj, name: "Y",
attr: { type: "range", value: point.y.toString(), min: "-50", max: "50", step: "0.1" },
update: (value) => { point.y = value; return { point: point.Clone() }; }
}
const zProps = {
obj, name: "Z",
attr: { type: "range", value: point.z.toString(), min: "0", max: "6", step: "0.1" },
update: (value) => { point.z = value; return { point: point.Clone() }; }
}
const tiltProps = {
obj, name: "傾斜",
attr: { type: "range", value: parameter.tilt.toString(), min: "0", max: "90" },
update: (value) => ({ tilt: value })
}
const orientationProps = {
obj, name: "方位角",
attr: { type: "range", value: parameter.orientation.toString(), min: "0", max: "360" },
update: (value) => ({ orientation: value })
}
return ObjectControl(
ControlRow(xProps),
ControlRow(yProps),
ControlRow(zProps),
ControlRow(tiltProps),
ControlRow(orientationProps));
// <ObjectControl>
// <ControlRow props={xProps}/>
// <ControlRow props={yProps}/>
// <ControlRow props={zProps}/>
// <ControlRow props={tiltProps}/>
// <ControlRow props={orientationProps}/>
// </ObjectControl>
}
export function ObjectAAControl(props) {
const { obj, parameter, axis, min, max } = props;
const point = parameter.point.Clone();
const rowProps = {
obj, name: axis.toUpperCase(),
attr: { type: "range", value: point[axis].toString(), min, max, step: "0.1" },
update: (value) => { point[axis] = value; return { point: point.Clone() }; }
};
console.log(rowProps);
return ObjectControl(ControlRow(rowProps));
// <ObjectControl>
// <ControlRow props={rowProps}/>
// </ObjectControl>
}
function ObjectControl(...children) {
return h("div", null,
h("hr"),
h("table", null, ...children));
// <div>
// <hr/>
// <table>{children}</table>
// </div>
}
function ControlRow(props) {
const { obj, name, attr, update } = props;
let input = h("input", attr);
input.addEventListener("input", () => {
const state = update(input.valueAsNumber);
obj.update(state);
});
return h("tr", null,
h("td", null, name),
h("td", null, input));
// <tr>
// <td>{ name }</td>
// <td><input {...attr} onInput={onInput}></input></td>
// </tr>
}
export function Legend(props) {
const { min, max, fieldUnit, colorSet, colorPos } = props;
return h(null, null,
LegendLabel({ min, max, fieldUnit }),
LegendBar({ colorSet, colorPos }));
// <>
// <LegendLabel props={ min, max, fieldUnit }/>
// <LegendBar props={ colorSet, colorPos }/>
// </>
}
function LegendLabel(props) {
const { min, max, fieldUnit } = props;
const style = { display: "flex", justifyContent: "space-between" };
return h("div", { style },
h("small", null, `${min} ${fieldUnit}`),
h("small", null, `${max} ${fieldUnit}`));
// <div style={style}>
// <small>{`${min} ${fieldUnit}`}</small>
// <small>{`${max} ${fieldUnit}`}</small>
// </div>
}
function LegendBar(props) {
const { colorSet, colorPos } = props;
const tokens = [];
for (let i = 0; i < colorSet.length && i < colorPos.length; i++) {
tokens.push(`${colorSet[i].substring(0, 7)} ${colorPos[i] * 100}%`);
}
const gradient = `linear-gradient(to right, ${tokens.join(", ")})`;
const style = { width: "100%", height: "20px", backgroundImage: gradient, margin: "5px 0" };
return h("div", { style });
// <div style={style}>
}
function h(name, attr, ...content) {
const e = (name && document.createElement(name)) || document.createDocumentFragment();
name && attr && Object.keys(attr).forEach(key => {
if (key in e) {
if (key === "style") {
Object.keys(attr.style).forEach(rule => e.style[rule] = attr.style[rule]);
} else {
e[key] = attr[key];
}
} else {
e.setAttribute(key, attr[key]);
}
});
content && content.forEach(c => {
if (Array.isArray(c)) {
e.append(...c);
} else if (c !== null && c !== false) {
e.append(c);
}
});
return e;
}
export function render(component, domNode) {
// 先清空內容
while (domNode.firstChild) {
domNode.removeChild(domNode.firstChild);
}
domNode.append(component);
}