|
|
|
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(80, window.innerWidth / window.innerHeight, 0.1, 1000 )
|
|
|
|
let homeContainer = new THREE.Group()
|
|
|
|
scene.add(homeContainer)
|
|
|
|
let home = new THREE.Group()
|
|
|
|
homeContainer.add(home)
|
|
|
|
|
|
|
|
let sunContainer = new THREE.Group()
|
|
|
|
|
|
|
|
let sunGeo = new THREE.SphereGeometry(1, 32, 32)
|
|
|
|
let sunMat = new THREE.MeshBasicMaterial({ color: metrics.color3 })
|
|
|
|
let sunMesh = new THREE.Mesh(sunGeo, sunMat)
|
|
|
|
sunMesh.transparent = true
|
|
|
|
|
|
|
|
sunContainer.add(sunMesh)
|
|
|
|
homeContainer.add(sunContainer)
|
|
|
|
|
|
|
|
// key: area name
|
|
|
|
// value: info
|
|
|
|
var areaData = { }
|
|
|
|
var areasWithEntitiesToUpdate = { }
|
|
|
|
|
|
|
|
var haInfo = { }
|
|
|
|
|
|
|
|
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)
|
|
|
|
}
|
|
|
|
|
|
|
|
let label = childWithName(scene, area + "LABEL")
|
|
|
|
label.lookAt(camera.position)
|
|
|
|
}
|
|
|
|
|
|
|
|
for (var area of areasToUpdate) {
|
|
|
|
let updatedEntities = areasWithEntitiesToUpdate[area]
|
|
|
|
// plh-evil: use these somehow?
|
|
|
|
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()
|
|
|
|
}
|
|
|
|
|
|
|
|
let sun = haInfo.sun
|
|
|
|
if (sun != null) {
|
|
|
|
let elevation = sun.attributes.elevation
|
|
|
|
let azimuth = sun.attributes.azimuth
|
|
|
|
|
|
|
|
sunMesh.position.y = 15
|
|
|
|
sunContainer.rotation.z = -1 * (azimuth / 360) * 2 * Math.PI
|
|
|
|
sunContainer.rotation.y = (elevation / 360) * 2 * Math.PI
|
|
|
|
}
|
|
|
|
|
|
|
|
renderer.render( scene, camera )
|
|
|
|
}
|
|
|
|
|
|
|
|
animate()
|
|
|
|
|
|
|
|
function createTextMesh(text, colorName) {
|
|
|
|
let canvas = document.createElement('canvas')
|
|
|
|
let context = canvas.getContext('2d')
|
|
|
|
let font = '144px monospace'
|
|
|
|
context.textAlign = "center"
|
|
|
|
context.font = font
|
|
|
|
|
|
|
|
let size = context.measureText(text)
|
|
|
|
let height = context.measureText('M').width * 1.5
|
|
|
|
size.height = height
|
|
|
|
|
|
|
|
let canvasScale = 1
|
|
|
|
canvas.width = size.width * canvasScale
|
|
|
|
canvas.height = size.height * canvasScale
|
|
|
|
|
|
|
|
context.font = font
|
|
|
|
|
|
|
|
console.log([canvas.width, canvas.height])
|
|
|
|
|
|
|
|
context.fillStyle = colorName
|
|
|
|
context.fillText(text, 0, 100)
|
|
|
|
|
|
|
|
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.005
|
|
|
|
|
|
|
|
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
|
|
|
|
// Special treatment for the sun
|
|
|
|
if (id == 'sun.sun') {
|
|
|
|
haInfo.sun = entity
|
|
|
|
}
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|