/** * 3D Foundation Project * Copyright 2025 Smithsonian Institution * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { Group, Mesh, MeshBasicMaterial, BufferGeometry, Vector3 } from "three"; import { customElement, html, render } from "@ff/ui/CustomElement"; import math from "@ff/core/math"; import FFColor from "@ff/core/Color"; import "@ff/ui/Button"; import AnnotationSprite, { Annotation, AnnotationElement } from "./AnnotationSprite"; import UniversalCamera from "@ff/three/UniversalCamera"; import AnnotationFactory from "./AnnotationFactory"; import {unsafeHTML} from 'lit-html/directives/unsafe-html.js'; //////////////////////////////////////////////////////////////////////////////// const _color = new FFColor(); const _offset = new Vector3(0, 0, 0); const _vec3a = new Vector3(); const _vec3b = new Vector3(); const _vec3c = new Vector3(); const _vec3d = new Vector3(); export default class CircleSprite extends AnnotationSprite { static readonly typeName: string = "Circle"; protected offset: Group; protected anchorMesh: Mesh; protected adaptive = true; protected originalHeight; protected originalWidth; constructor(annotation: Annotation) { super(annotation); this.offset = new Group(); this.offset.matrixAutoUpdate = false; this.add(this.offset); this.anchorMesh = new Mesh( new BufferGeometry(), new MeshBasicMaterial() ); this.anchorMesh.frustumCulled = false; this.anchorMesh.matrixAutoUpdate = false; this.offset.add(this.anchorMesh); this.update(); } dispose() { this.offset = null; this.anchorMesh?.geometry.dispose(); (this.anchorMesh?.material as MeshBasicMaterial).dispose(); this.anchorMesh = null; super.dispose(); } update() { const annotation = this.annotation.data; this.anchorMesh.scale.setScalar(annotation.scale); this.anchorMesh.position.y = annotation.offset; this.anchorMesh.updateMatrix(); super.update(); } renderHTMLElement(element: AnnotationElement, bounds: DOMRect, camera: UniversalCamera) { super.renderHTMLElement(element, bounds, camera, this.anchorMesh, _offset); // Override viewAngle calculation using temporary offset const anchor = this.anchorMesh; _vec3a.set(0, 0, 0); _vec3a.applyMatrix4(anchor.modelViewMatrix); _vec3b.set(0, 1, 0); _vec3b.applyMatrix4(anchor.modelViewMatrix); _vec3c.copy(_vec3b).sub(_vec3a).normalize(); _vec3d.set(0, 0, 1); this.viewAngle = _vec3c.angleTo(_vec3d); // Set opacity based on viewAngle const angleOpacity = math.scaleLimit(this.viewAngle * math.RAD2DEG, 90, 100, 1, 0); const opacity = this.annotation.data.visible ? angleOpacity : 0; element.setOpacity(opacity); const annotation = this.annotation.data; const isShowing = annotation.visible; this.offset.visible = isShowing; // update adaptive settings if(this.adaptive !== this.isAdaptive) { if(!this.isAdaptive) { element.truncated = false; element.classList.remove("sv-short"); element.requestUpdate(); } this.adaptive = this.isAdaptive; } // don't show if behind the camera this.setVisible(!this.isBehindCamera(this.offset, camera) && isShowing && this.annotation.data.visible); if(!this.getVisible()) { element.setVisible(this.getVisible()); } // check if annotation is out of bounds and update if needed if (this.adaptive && !this.isAnimating && annotation.expanded) { if(!element.truncated) { if(!element.classList.contains("sv-expanded")) { element.requestUpdate().then(() => { this.originalHeight = element.offsetHeight; this.originalWidth = element.offsetWidth; this.checkTruncate(element, bounds); }); } else { this.originalHeight = element.offsetHeight; this.originalWidth = element.offsetWidth; } } this.checkTruncate(element, bounds); } } protected createHTMLElement() { return new CircleAnnotation(this); } // Helper function to check if annotation should truncate protected checkTruncate(element: AnnotationElement, bounds: DOMRect) { const x = element.getBoundingClientRect().left - bounds.left; const y = element.getBoundingClientRect().top - bounds.top; const shouldTruncate = y + this.originalHeight >= bounds.height; if(shouldTruncate !== element.truncated) { element.truncated = shouldTruncate; element.requestUpdate().then(() => { this.checkBounds(element, bounds); }); } else { this.checkBounds(element, bounds); } } // Helper function to check and handle annotation overlap with bounds of container protected checkBounds(element: AnnotationElement, bounds: DOMRect) { const x = element.getBoundingClientRect().left - bounds.left; const y = element.getBoundingClientRect().top - bounds.top; if (x + element.offsetWidth >= bounds.width && !element.classList.contains("sv-align-right")) { element.classList.add("sv-align-right"); element.requestUpdate(); } else if (x + element.offsetWidth < bounds.width && element.classList.contains("sv-align-right")){ element.classList.remove("sv-align-right"); element.requestUpdate(); } if (y + element.offsetHeight >= bounds.height && !element.classList.contains("sv-align-bottom")) { element.classList.add("sv-align-bottom"); element.requestUpdate(); } else if (y + element.offsetHeight < bounds.height && element.classList.contains("sv-align-bottom")) { element.classList.remove("sv-align-bottom"); element.requestUpdate(); } } } AnnotationFactory.registerType(CircleSprite); //////////////////////////////////////////////////////////////////////////////// @customElement("sv-circle-annotation") class CircleAnnotation extends AnnotationElement { protected markerElement: HTMLDivElement; protected contentElement: HTMLDivElement; protected isExpanded = undefined; constructor(sprite: CircleSprite) { super(sprite); this.onClickMarker = this.onClickMarker.bind(this); this.onClickArticle = this.onClickArticle.bind(this); this.onClickAudio = this.onClickAudio.bind(this); this.onKeyDown = this.onKeyDown.bind(this); this.onKeyDownArticle = this.onKeyDownArticle.bind(this); this.onClickOverlay = this.onClickOverlay.bind(this); this.addEventListener("keydown", this.onKeyDown); this.markerElement = this.appendElement("div"); this.markerElement.classList.add("sv-marker"); this.markerElement.addEventListener("click", this.onClickMarker); this.markerElement.setAttribute("tabindex", "0"); this.contentElement = this.appendElement("div"); this.contentElement.classList.add("sv-annotation-body"); this.contentElement.style.display = "none"; } setVisible(visible: boolean) { this.style.display = visible ? "flex" : "none"; } protected firstConnected() { super.firstConnected(); this.classList.add("sv-circle-annotation"); } protected updated(changedProperties): void { super.updated(changedProperties); const annotation = this.sprite.annotation; const annotationData = annotation.data; const isTruncated = !this.overlayed && this.truncated && (annotationData.imageUri || annotationData.articleId || annotation.lead.length > 0); // make sure we have content to truncate const audio = this.sprite.audioManager; // update title this.markerElement.innerText = annotationData.marker; const contentTemplate = html` ${!this.isOverlayed ? html`
${unsafeHTML(annotation.lead)}