You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
342 lines
10 KiB
342 lines
10 KiB
let haStateURL = "http://beacon:1880/ha_state" |
|
|
|
let metrics = { |
|
roomDefaultOpacity : 0.15, |
|
roomHighlightOpacity : 0.6, |
|
roomHighlightDuration : 0.3, |
|
roomUnhighlightDuration: 0.3, |
|
animationCurve : TWEEN.Easing.Cubic.InOut |
|
} |
|
|
|
const scene = new THREE.Scene() |
|
const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 ) |
|
let homeContainer = new THREE.Group() |
|
scene.add(homeContainer) |
|
let home = new THREE.Group() |
|
homeContainer.add(home) |
|
|
|
// key: area name |
|
// value: info |
|
var areaData = { } |
|
var areasWithEntitiesToUpdate = { } |
|
|
|
const renderer = new THREE.WebGLRenderer({ |
|
logarithmicDepthBuffer : true |
|
}) |
|
renderer.setSize( window.innerWidth, window.innerHeight ) |
|
document.body.appendChild( renderer.domElement ) |
|
|
|
const light = new THREE.AmbientLight(0xFFAAAA, 1) |
|
light.position.z = 100 |
|
scene.add(light) |
|
scene.background = new THREE.Color("white") |
|
|
|
// back the camera up |
|
camera.position.z = 25 |
|
|
|
// flip home on x axis |
|
home.rotation.x = Math.PI |
|
|
|
// Rotate container a bit for comfort |
|
homeContainer.rotation.x = 0.6 * (-Math.PI / 2) |
|
|
|
// Returns the first child (or container) whose name matches |
|
function childWithName(container, name) { |
|
if (container.userData.name == name) { |
|
return container; |
|
} |
|
for (var child of container.children) { |
|
let childMatches = childWithName(child, name) |
|
if (childMatches != null) { |
|
return childMatches |
|
} |
|
} |
|
return null |
|
} |
|
|
|
function animate() { |
|
requestAnimationFrame( animate ) |
|
TWEEN.update() |
|
|
|
var areasToUpdate = new Set() |
|
for (var area of Object.keys(areasWithEntitiesToUpdate)) { |
|
let updatedEntities = areasWithEntitiesToUpdate[area] |
|
if (updatedEntities.length > 0) { |
|
areasToUpdate.add(area) |
|
} |
|
} |
|
for (var area of areasToUpdate) { |
|
let areaContainer = childWithName(scene, area) |
|
let box = childWithName(areaContainer, "box") |
|
|
|
let fadeIn = new TWEEN.Tween(box.material) |
|
.easing(metrics.animationCurve) |
|
.to( { opacity: metrics.roomHighlightOpacity }, metrics.roomHighlightDuration * 1000) |
|
|
|
let fadeOut = new TWEEN.Tween(box.material) |
|
.easing(metrics.animationCurve) |
|
.to( { opacity: metrics.roomDefaultOpacity }, metrics.roomUnhighlightDuration * 1000) |
|
|
|
// This will rotate to the area that has a change |
|
let rotate = new TWEEN.Tween(homeContainer.rotation) |
|
.easing(metrics.animationCurve) |
|
.to( { z: areaData[area].angle }, 2000 ) |
|
|
|
rotate.chain(fadeIn) |
|
fadeIn.chain(fadeOut) |
|
rotate.start() |
|
|
|
} |
|
|
|
// homeContainer.rotation.z += 0.003 |
|
|
|
renderer.render( scene, camera ) |
|
} |
|
|
|
animate() |
|
|
|
function createTextMesh(text, colorName) { |
|
let canvas = document.createElement('canvas') |
|
let context = canvas.getContext('2d') |
|
context.textAlign = "center" |
|
context.textBaseline = "middle" |
|
context.fillStyle = colorName |
|
context.font = '144pt sans-serif' |
|
|
|
let size = context.measureText(text) |
|
let height = context.measureText('M').width |
|
size.height = height |
|
|
|
let canvasScale = 0.1 |
|
canvas.width = size.width * canvasScale * 0.6 |
|
canvas.height = size.height * canvasScale |
|
|
|
console.log([canvas.width, canvas.height]) |
|
|
|
context.fillStyle = 'red' |
|
context.fillRect(0, 0, canvas.width, canvas.height) |
|
context.fillStyle = 'blue' |
|
context.fillText(text, 5, 10) |
|
|
|
let texture = new THREE.CanvasTexture(canvas) |
|
texture.needsUpdate = true |
|
let material = new THREE.MeshBasicMaterial({ |
|
map: texture |
|
}) |
|
material.transparent = true |
|
|
|
let scaleFactor = 0.05 |
|
|
|
let plane = new THREE.PlaneGeometry(canvas.width * scaleFactor, canvas.height * scaleFactor) |
|
let mesh = new THREE.Mesh(plane, material) |
|
|
|
return mesh |
|
} |
|
|
|
var lastUpdateTime = new Date() |
|
function updateWithHAData(data) { |
|
// `data` is an array of entities. loop through and group by area |
|
var areasToEntities = { } |
|
var areasToUpdatedEntities = { } |
|
var areaNames = new Array() |
|
for (var name of Object.keys(areaData)) { |
|
areaNames.push(name) |
|
areasToEntities[name] = new Array() |
|
areasToUpdatedEntities[name] = new Array() |
|
} |
|
|
|
let sortedAreaNames = areaNames |
|
sortedAreaNames.sort(function(a, b) { |
|
return b.length - a.length; |
|
}) |
|
|
|
for (var entity of data) { |
|
let id = entity.entity_id |
|
let friendlyName = entity.attributes.friendly_name || entity.entity_id |
|
for (var area of sortedAreaNames) { |
|
let matchesUnderscored = id.includes(area.toLowerCase().replace(' ','_')) |
|
let matchesNoSpaces = id.includes(area.toLowerCase().replace(' ','')) |
|
let matchesFriendlyName = friendlyName.includes(area) |
|
|
|
let matches = matchesUnderscored || matchesNoSpaces || matchesFriendlyName |
|
|
|
if (matches) { |
|
let areaEntities = areasToEntities[area] |
|
areaEntities.push(entity) |
|
areasToEntities[area] = areaEntities |
|
|
|
let lastUpdate = new Date(entity.last_changed) |
|
if (lastUpdate > lastUpdateTime) { |
|
let areaUpdatedEntities = areasToUpdatedEntities[area] |
|
areaUpdatedEntities.push(entity) |
|
areasToUpdatedEntities[area] = areaUpdatedEntities |
|
} |
|
} |
|
} |
|
} |
|
|
|
lastUpdateTime = new Date() |
|
areasWithEntitiesToUpdate = areasToUpdatedEntities |
|
} |
|
|
|
async function loadHAData() { |
|
let request = new Request(haStateURL) |
|
fetch(request) |
|
.then(response => { |
|
return response.json() |
|
}) |
|
.then(json => { |
|
updateWithHAData(json) |
|
new Promise(resolve => setTimeout(resolve, 250)) |
|
.then(_ => { |
|
loadHAData() |
|
}) |
|
}) |
|
} |
|
|
|
function setPosition(mesh, x, y, z, w, h, d) { |
|
// position sets the *center* position |
|
// our coordinates are top/left/bottom |
|
|
|
let meshX = x + w/2 |
|
let meshY = y + h/2 |
|
let meshZ = -z - d/2 |
|
mesh.position.x = meshX |
|
mesh.position.y = meshY |
|
mesh.position.z = meshZ |
|
} |
|
|
|
function configureScene(data) { |
|
var minX = 1000 |
|
var maxX = -1000 |
|
var minY = minX |
|
var maxY = maxX |
|
var minZ = minX |
|
var maxZ = maxX |
|
|
|
// Add geometry for the rooms |
|
for (var room of data.rooms) { |
|
let roomContainer = new THREE.Group() |
|
roomContainer.userData.name = room.name |
|
areaData[room.name] = room |
|
|
|
let roomGeo = new THREE.BoxGeometry(room.w, room.h, room.d) |
|
let roomColor = new THREE.Color(room.color) |
|
let roomBoxMaterial = new THREE.MeshPhysicalMaterial({ color: roomColor }) |
|
roomBoxMaterial.transparent = true |
|
roomBoxMaterial.opacity = metrics.roomDefaultOpacity |
|
let roomMesh = new THREE.Mesh(roomGeo, roomBoxMaterial) |
|
roomMesh.userData.name = "box" |
|
roomContainer.add(roomMesh) |
|
|
|
let roomEdgesGeo = new THREE.EdgesGeometry(roomGeo) |
|
let roomLinesMaterial = new THREE.LineBasicMaterial({ color: roomColor }) |
|
roomLinesMaterial.linewidth = 3 |
|
let roomLines = new THREE.LineSegments(roomEdgesGeo, roomLinesMaterial) |
|
roomLines.userData.name = "lines" |
|
roomContainer.add(roomLines) |
|
|
|
let roomZ = room.z || 0 |
|
|
|
setPosition(roomContainer, room.x, room.y, roomZ, room.w, room.h, room.d) |
|
home.add(roomContainer) |
|
|
|
let roomLabel = createTextMesh(room.name, room.color) |
|
roomLabel.position.x = roomContainer.position.x |
|
roomLabel.position.y = roomContainer.position.y |
|
roomLabel.position.z = roomZ |
|
roomLabel.renderOrder = 5000 |
|
// not yet |
|
// homeContainer.add(roomLabel) |
|
|
|
minX = Math.min(minX, room.x) |
|
minY = Math.min(minY, room.y) |
|
minZ = Math.min(minZ, roomZ) |
|
|
|
maxX = Math.max(maxX, room.x + room.w) |
|
maxY = Math.max(maxY, room.y + room.h) |
|
maxZ = Math.max(maxZ, roomZ + room.d) |
|
} |
|
|
|
let xExtent = maxX - minX |
|
let yExtent = maxY - minY |
|
let zExtent = maxZ - minZ |
|
|
|
home.position.x = -1 * (xExtent / 2) |
|
home.position.y = +1 * (yExtent / 2) |
|
home.position.z = -1 * (zExtent / 2) |
|
|
|
let homeCenterX = minX + (xExtent / 2) |
|
let homeCenterY = minY + (yExtent / 2) |
|
let homeCenterZ = minZ + (zExtent / 2) |
|
|
|
for (var room of data.rooms) { |
|
let name = room.name |
|
let centerX = room.x + room.w / 2 |
|
let centerY = room.y + room.h / 2 |
|
let centerZ = (room.z || 0) + room.d / 2 |
|
|
|
let unitX = (centerX - homeCenterX) / (xExtent / 2) |
|
let unitY = (centerY - homeCenterY) / (yExtent / 2) |
|
let unitZ = (centerZ - homeCenterZ) / (zExtent / 2) |
|
|
|
let radiusXY = Math.sqrt(unitX * unitX + unitY * unitY) |
|
unitX *= (1.0 / radiusXY) |
|
unitY *= (1.0 / radiusXY) |
|
|
|
// Compute rotations |
|
let angleX = Math.acos(unitX) |
|
let angleY = Math.asin(unitY) |
|
let angle = 0 |
|
|
|
if (unitX > 0 && unitY > 0) { |
|
// First quadrant |
|
if (angleX < Math.PI/2) { |
|
angle = angleX |
|
} else { |
|
angle = angleY |
|
} |
|
} else if (unitX < 0 && unitY > 0) { |
|
// Second quadrant |
|
if (angleX > Math.PI/2 && angleX < Math.PI) { |
|
angle = angleX |
|
} else { |
|
angle = angleY |
|
} |
|
} else if (unitX < 0 && unitY < 0) { |
|
// Third quadrant |
|
if (angleX > Math.PI && angleX < Math.PI * 1.5) { |
|
angle = angleX |
|
} else { |
|
angle = angleY |
|
} |
|
} else { |
|
// Fourth quadrant |
|
if (angleX > Math.PI * 1.5 && angleX < Math.PI * 2) { |
|
angle = angleX |
|
} else { |
|
angle = angleY |
|
} |
|
} |
|
|
|
// update in areaData |
|
areaData[room.name].angle = angle - Math.PI/2 |
|
} |
|
} |
|
|
|
async function loadHomeData() { |
|
let request = new Request('data.json') |
|
fetch(request) |
|
.then(response => { |
|
return response.json() |
|
}) |
|
.then(json => { |
|
configureScene(json) |
|
// Kick off first HA load |
|
loadHAData() |
|
}) |
|
} |
|
|
|
loadHomeData() |
|
|
|
|
|
|