let haStateURL = "http://beacon:1880/ha_state" let metrics = { roomDefaultOpacity : 0.15, roomHighlightOpacity : 0.6, roomHighlightDuration : 0.3, roomUnhighlightDuration : 0.3, ringThickness : 0.4, ringTicks : 60, ringTickLength : 1, ringTickDimension : 0.07, animationCurve : TWEEN.Easing.Cubic.InOut, // colors backgroundColor : '#363537', color1 : '#EF2D56', color2 : '#ED7D3A', color3 : '#8CD867', color4 : '#2FBF71' } 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(metrics.backgroundColor) // 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) } else { } let label = childWithName(scene, area + "LABEL") label.lookAt(camera.position) } 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) .delay(1000) .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 ) let label = childWithName(scene, area + "LABEL") let labelScaleIn = new TWEEN.Tween(label.material) .easing(metrics.animationCurve) .to( { opacity: 1.0 }, 500) let labelScaleOut = new TWEEN.Tween(label.material) .delay(1000) .easing(metrics.animationCurve) .to( { opacity: 0.0 }, 500) rotate.chain(fadeIn, labelScaleIn) fadeIn.chain(fadeOut) labelScaleIn.chain(labelScaleOut) 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.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 context.fillStyle = colorName context.fillText(text, 5, 10) let texture = new THREE.CanvasTexture(canvas) texture.needsUpdate = true let material = new THREE.MeshBasicMaterial({ map: texture }) material.transparent = true material.depthTest = false 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(metrics.color1) 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: new THREE.Color(metrics.color2) }) roomLinesMaterial.transparent = true roomLinesMaterial.linewidth = 4 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, metrics.color3) roomLabel.material.opacity = 0 roomLabel.userData.name = room.name + "LABEL" roomContainer.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 // position label let label = childWithName(scene, room.name + "LABEL") let radiusForLabel = radiusXY label.position.x += (unitX * 6) label.position.y += (unitY * 6) label.position.z += (unitZ * 6) } let ringDimension = Math.max(xExtent, yExtent) * 0.8 let ringGeo = new THREE.RingGeometry(ringDimension - metrics.ringThickness / 2, ringDimension + metrics.ringThickness / 2, 128) let ringMaterial = new THREE.MeshBasicMaterial( { color: new THREE.Color(metrics.color1), side: THREE.DoubleSide } ) ringMaterial.transparent = true let ring = new THREE.Mesh(ringGeo, ringMaterial) ring.userData.name = "ring" ring.position.x = homeCenterX ring.position.y = homeCenterY home.add(ring) let tickGeo = new THREE.BoxGeometry(metrics.ringTickDimension, metrics.ringTickLength, metrics.ringTickDimension) for (var tickIndex = 0; tickIndex < metrics.ringTicks; tickIndex++) { let tickRadianStep = (2 * Math.PI) / metrics.ringTicks let tickRadians = tickRadianStep * tickIndex let tickContainer = new THREE.Group() let tickColor = new THREE.Color('black') let tickMaterial = new THREE.MeshPhysicalMaterial({ color: new THREE.Color(metrics.color2) }) tickMaterial.transparent = true let tickMesh = new THREE.Mesh(tickGeo, tickMaterial) tickContainer.rotation.z = tickRadians tickMesh.position.y = ringDimension tickContainer.add(tickMesh) ring.add(tickContainer) tickContainer.userData.name = tickIndex tickMesh.userData.name = "box" let tickPulse = new TWEEN.Tween(tickMesh.scale) .easing(metrics.animationCurve) .delay(THREE.MathUtils.randFloat(0, 4000)) .to( { y: 2 }, 1000 ) .yoyo(true) .repeat(Infinity) .start() } } 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()