diff --git a/src/Accessibility/makeMenuTabbable.js b/src/Accessibility/makeMenuTabbable.js new file mode 100644 index 0000000..04821f3 --- /dev/null +++ b/src/Accessibility/makeMenuTabbable.js @@ -0,0 +1,492 @@ +/** + * Makes menu tabbable keyboard accessible. + */ +export function makeMenuTabbable() { + makeMenuToggleTabbable() + makeMiniMapTabbable() + makePanelsTabbable() + makeElevationControlTabbable() + makeAcceptedFilteringTabbable() + makeObjectsTabbable() + makeObjectsDropdownsTabbable() + makeMeasurementsTabbable() + makeAppearancePanelTabbable() + makeToolsPanelTabbable() +} + +/** + * Makes menu toggle tabbable and adds keyboard event listeners. + */ +function makeMenuToggleTabbable() { + const quickButtonsContainer = document.getElementById('potree_quick_buttons') + if (!quickButtonsContainer) return + const toggle = quickButtonsContainer.querySelector('.potree_menu_toggle') + if (toggle) { + toggle.tabIndex = 0 + toggle.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + toggle.click() + } + }) + } +} + +/** + * Makes minimap tabbable and keyboard clickable + */ +function makeMiniMapTabbable() { + const quickButtonsContainer = document.getElementById('potree_quick_buttons') + if (!quickButtonsContainer) return + const toggle = quickButtonsContainer.querySelector('#potree_map_toggle') + if (toggle) { + toggle.tabIndex = 0 + toggle.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + toggle.click() + } + }) + } +} + +/** + * Makes accordion titles tabbable and keyboard clickable + */ +function makePanelsTabbable() { + const menu = document.getElementById('potree_menu') + if (menu) { + const headers = menu.querySelectorAll('h3') + headers.forEach((header) => { + header.tabIndex = 0 + header.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + header.click() + } + }) + }) + } +} + +/** + * Makes elevation control panel tabbable and keyboard clickable + */ +function makeElevationControlTabbable() { + // Make activate elevation control button tabbable and keyboard clickable + const activateButton = document.getElementById('btnDoElevationControl') + if (activateButton) { + activateButton.tabIndex = 0 + activateButton.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + activateButton.click() + activateButton.focus() + } + }) + } + + // Make gradient clamp, repeat and mirrored repeat buttons tabbable and keyboard clickable + const gradientButtons = document.querySelectorAll( + '#gradient_repeat_option label' + ) + gradientButtons.forEach((label) => { + label.tabIndex = 0 + label.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + label.click() + } + }) + }) + + // Make gradient schemes tabbable and keyboard clickable + const gradientSpans = document.querySelectorAll( + '#elevation_gradient_scheme_selection span' + ) + gradientSpans.forEach((span) => { + span.tabIndex = 0 + span.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + span.click() + } + }) + }) +} + +/** + * Makes accepted filtering panel tabbable and keyboard clickable + */ +function makeAcceptedFilteringTabbable() { + const activateButton = document.getElementById('doAcceptedFiltering') + if (activateButton) { + activateButton.tabIndex = 0 + activateButton.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + activateButton.click() + activateButton.focus() + } + }) + } +} + +/** + * Makes appearance panel tabbable and keyboard clickable + */ +function makeAppearancePanelTabbable() { + // Make EDL checkbox tabbable and keyboard clickable + const edlCheckbox = document.getElementById('chkEDLEnabled') + if (edlCheckbox) { + edlCheckbox.tabIndex = 0 + edlCheckbox.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + edlCheckbox.click() + } + }) + } + + // Make background buttons tabbable and keyboard clickable + const backgroundButtons = document.querySelectorAll( + '#background_options label' + ) + backgroundButtons.forEach((label) => { + label.tabIndex = 0 + label.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + label.click() + } + }) + }) + + // Make splat quality buttons tabbable and keyboard clickable + const splatQualityButtons = document.querySelectorAll( + '#splat_quality_options label' + ) + splatQualityButtons.forEach((label) => { + label.tabIndex = 0 + label.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + label.click() + } + }) + }) + + // Make box checkbox tabbable and keyboard clickable + const boxCheckbox = document.getElementById('show_bounding_box') + if (boxCheckbox) { + boxCheckbox.tabIndex = 0 + boxCheckbox.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + boxCheckbox.click() + } + }) + } + + // Make lock view checkbox tabbable and keyboard clickable + const lockViewCheckBox = document.getElementById('set_freeze') + if (lockViewCheckBox) { + lockViewCheckBox.tabIndex = 0 + lockViewCheckBox.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + lockViewCheckBox.click() + } + }) + } +} + +/** + * Makes tools panel tabbable and keyboard clickable + */ +function makeToolsPanelTabbable() { + // Make clipping tools tabbable and keyboard clickable + const clippingTools = document.querySelectorAll('#clipping_tools img') + clippingTools.forEach((img, index) => { + // Hide unsupported tool + if (index === 2) { + img.hidden = true + return + } + img.tabIndex = 0 + img.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + img.click() + } + }) + }) + + // Make clip task buttons tabbable and keyboard clickable + const clipTaskButtons = document.querySelectorAll('#cliptask_options label') + clipTaskButtons.forEach((label) => { + label.tabIndex = 0 + label.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + label.click() + } + }) + }) + + // Make clip method buttons tabbable and keyboard clickable + const clipMethodButtons = document.querySelectorAll( + '#clipmethod_options label' + ) + clipMethodButtons.forEach((label) => { + label.tabIndex = 0 + label.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + label.click() + } + }) + }) + + // Hide camera projection options as they are not supported + const cameraProjection = document.getElementById('camera_projection_options') + if (cameraProjection) cameraProjection.hidden = true + + // Make navigation tools tabbable and keyboard clickable + const navigationTools = document.querySelectorAll('#navigation img') + const renderArea = document.querySelector('#potree_render_area') + // Keep wrapper focusable as a fallback target (doesn't change tab order elsewhere) + if (renderArea && !renderArea.hasAttribute('tabindex')) { + renderArea.setAttribute('tabindex', '0') + } + + navigationTools.forEach((img) => { + img.tabIndex = 0 + img.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + img.click() + + // Switch controls directly via Potree API, then focus the keyboard input target (canvas) + const pv = window.potreeViewer || window.viewer || null + const target = renderArea + if (pv) { + const src = (img.getAttribute && img.getAttribute('src')) || '' + const title = + img.getAttribute && + (img.getAttribute('title') || img.getAttribute('data-i18n') || '') + const key = (src + ' ' + title).toLowerCase() + + if (key.includes('earth')) { + pv.setControls(pv.earthControls) + } else if (key.includes('heli') || key.includes('helicopter')) { + pv.setControls(pv.fpControls) + if (pv.fpControls) pv.fpControls.lockElevation = true + } else if ( + key.includes('fps') || + key.includes('flight') || + key.includes('flight_control') + ) { + pv.setControls(pv.fpControls) + if (pv.fpControls) pv.fpControls.lockElevation = false + } else if (key.includes('orbit')) { + pv.setControls(pv.orbitControls) + } + + const dom = pv && pv.renderer && pv.renderer.domElement + if (dom) { + if (!dom.hasAttribute('tabindex')) + dom.setAttribute('tabindex', '-1') + dom.focus() + } else if (target) { + // conservative fallback + if (!target.hasAttribute('tabindex')) + target.setAttribute('tabindex', '0') + target.focus() + } + } else if (target) { + if (!target.hasAttribute('tabindex')) + target.setAttribute('tabindex', '0') + target.focus() + } + } + }) + }) +} + +function makeMeasurementsTabbable() { + // Select every tool inside the measurement tools host + const tools = document.querySelectorAll( + '#measurement_tools_host .tool-with-label' + ) + tools.forEach((tool) => { + tool.tabIndex = 0 + tool.setAttribute('role', 'button') + tool.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + tool.click() + } + }) + }) +} + +/** + * Makes the Scene > Objects tree tabbable and keyboard operable + * - Tab to anchors + * - Enter/Space = select (click) + * - ArrowRight = open node, ArrowLeft = close node (uses jsTree if available) + * - ArrowUp/ArrowDown = move focus to previous/next visible item + * - Home/End = first/last visible item + */ +function makeObjectsTabbable() { + const install = (tree) => { + if (!tree) return + + // Ensure all anchors are tabbable + const setTabbable = (root) => { + root.querySelectorAll('a.jstree-anchor').forEach((a) => { + a.tabIndex = 0 + }) + } + setTabbable(tree) + + // Keep anchors tabbable when jsTree updates DOM + const mo = new MutationObserver((muts) => { + for (const m of muts) { + if (m.addedNodes && m.addedNodes.length) { + m.addedNodes.forEach((n) => { + if (n.nodeType === 1) setTabbable(n) + }) + } + } + }) + mo.observe(tree, { childList: true, subtree: true }) + + const getAnchors = () => + Array.from(tree.querySelectorAll('a.jstree-anchor')) + const focusMove = (from, dir) => { + const anchors = getAnchors() + const i = anchors.indexOf(from) + const next = anchors[i + dir] + if (next) next.focus() + } + const focusEdge = (toLast) => { + const anchors = getAnchors() + const target = toLast ? anchors[anchors.length - 1] : anchors[0] + if (target) target.focus() + } + + // Delegate key handling on anchors + tree.addEventListener('keydown', (e) => { + const a = e.target + if ( + !a || + !(a instanceof HTMLElement) || + !a.classList.contains('jstree-anchor') + ) + return + const li = a.closest('li') + const id = li && li.id + const $ = window.jQuery || window.$ + const inst = $ && $(tree).jstree ? $(tree).jstree(true) : null + + switch (e.key) { + case 'Enter': + case ' ': // space + e.preventDefault() + a.click() + break + case 'ArrowDown': + e.preventDefault() + focusMove(a, +1) + break + case 'ArrowUp': + e.preventDefault() + focusMove(a, -1) + break + case 'Home': + e.preventDefault() + focusEdge(false) + break + case 'End': + e.preventDefault() + focusEdge(true) + break + case 'ArrowRight': + e.preventDefault() + if (inst && id) { + inst.open_node(id) + } else if (li && li.classList.contains('jstree-closed')) { + const toggle = li.querySelector('.jstree-ocl') + if (toggle) + toggle.dispatchEvent(new MouseEvent('click', { bubbles: true })) + } + break + case 'ArrowLeft': + e.preventDefault() + if (inst && id) { + inst.close_node(id) + } else if (li && li.classList.contains('jstree-open')) { + const toggle = li.querySelector('.jstree-ocl') + if (toggle) + toggle.dispatchEvent(new MouseEvent('click', { bubbles: true })) + } + break + } + }) + } + + const tryInit = () => { + const tree = document.getElementById('scene_objects') + if (tree) { + install(tree) + return true + } + return false + } + + if (!tryInit()) { + // Wait for GUI to load + const obs = new MutationObserver(() => { + if (tryInit()) obs.disconnect() + }) + obs.observe(document.body, { childList: true, subtree: true }) + } +} + +/** + * Make dropdowns in Properties tabbable: focus the associated input/select when label is activated via keyboard. + */ +function makeObjectsDropdownsTabbable() { + const make = () => { + const container = document.getElementById('potree_menu') + if (!container) return false + + // Labels that point to inputs/selects + container.querySelectorAll('label[for]').forEach((lbl) => { + lbl.tabIndex = 0 + lbl.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + const id = lbl.getAttribute('for') + const target = id && document.getElementById(id) + if (target instanceof HTMLElement) target.focus() + } + }) + }) + + // Ensure selects are in tab order (usually default) and openable via keyboard focus + container.querySelectorAll('select').forEach((sel) => { + sel.tabIndex = 0 + }) + return true + } + + if (!make()) { + const obs = new MutationObserver(() => { + if (make()) obs.disconnect() + }) + obs.observe(document.body, { childList: true, subtree: true }) + } +} diff --git a/src/Filter/filter.js b/src/Filter/filter.js index 981b6a8..5fd6b74 100644 --- a/src/Filter/filter.js +++ b/src/Filter/filter.js @@ -211,11 +211,11 @@ function setUpElevationSlider(hooks) { const update = () => { const low = slider.slider('values', 0) const high = slider.slider('values', 1) - label.textContent = `${low.toFixed(2)} to ${high.toFixed(2)}` + label.textContent = `${Math.round(low)} to ${Math.round(high)}` hooks?.onElevationRangeChange([low, high]) } - slider.slider({ min: -10000, max: 0, values: [-10000, 0] }) + slider.slider({ min: -10000, max: 0, values: [-10000, 0], step: 1 }) slider.off('slide.custom slidestop.custom change.custom') slider.on('slide.custom slidestop.custom change.custom', update) update() diff --git a/src/cameraSync.js b/src/cameraSync.js index cead992..4b40b76 100644 --- a/src/cameraSync.js +++ b/src/cameraSync.js @@ -58,11 +58,7 @@ export function syncCameras(potreeViewer, cesiumViewer) { } /** - * Determines whether the globe should be visible based on the camera position. - * - * Returns false if the camera is below the globe surface, if the pivot - * point would be blocked by the curvature of the Earth or if the camera - * is looking almost straight down at the pivot. + * Determines whether the globe should be visible based on the camera position and controls. * * @param cameraPos - The camera position in Cesium.Cartesian3 coordinates * @param pivot - The pivot point in Cesium.Cartesian3 coordinates @@ -99,7 +95,17 @@ function shouldShowGlobe(cameraPos, pivot, direction) { const targetNormal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(pivot) const dotProduct = Math.abs(Cesium.Cartesian3.dot(direction, targetNormal)) - // If camera is "above" pivot on the axis, and not looking nearly straight down, the globe should be visible - // Otherwise, the globe should not be visible - return camProj >= pivotProj && dotProduct < 0.99 + // Get the camera's elevation based on ellipsoid + const cameraCarto = Cesium.Cartographic.fromCartesian(cameraPos, ellipsoid) + const elevation = cameraCarto.height + + // Determine globe visibility based on camera controls + if (window.potreeViewer.getControls() === window.potreeViewer.orbitControls) { + // If camera is "above" pivot on the axis, and not looking nearly straight down, the globe should be visible + // Otherwise, the globe should not be visible + return camProj >= pivotProj && dotProduct < 0.99 + } else { + // If the camera is inside the globe, or looking nearly straight down, hide the globe + return elevation > 0 && dotProduct < 0.99 + } } diff --git a/src/main.js b/src/main.js index 87aaafe..45714a4 100644 --- a/src/main.js +++ b/src/main.js @@ -21,7 +21,6 @@ async function init() { POTREE_POINTCLOUD_URLS, POTREE_SETTINGS ) - potreeViewer.addEventListener('camera_changed', updateText) setupRightClickListener(potreeViewer) diff --git a/src/potreeViewer.js b/src/potreeViewer.js index b78539d..f16ebcd 100644 --- a/src/potreeViewer.js +++ b/src/potreeViewer.js @@ -1,6 +1,7 @@ import { initAnnotationsPanel } from './AnnotationControl/annotationPanel.js' import { initMeasurementsPanel } from './MeasurementControl/measurementsPanel.js' import { initMiniMap } from './MiniMap/miniMap.js' +import { makeMenuTabbable } from './Accessibility/makeMenuTabbable.js' import { initFilterPanels, toggleAcceptedLegend } from './Filter/filter.js' import { ecef } from './config.js' import { init2DProfileOverride } from './2DProfileOverride/2DProfileOverride.js' @@ -56,6 +57,8 @@ export async function createPotreeViewer( viewer.loadGUI(() => { viewer.setLanguage('en') + //remove the header with language information + $('#sidebar_header').remove() $('#menu_filters').remove() viewer.toggleSidebar() @@ -129,6 +132,8 @@ export async function createPotreeViewer( initMeasurementsPanel(viewer) initAnnotationsPanel(viewer) initMiniMap(viewer) + + makeMenuTabbable() }) // Initialize camera position and target point (manually chosen)