diff --git a/bundle.js b/bundle.js
index c9299a5..7e3366d 100644
--- a/bundle.js
+++ b/bundle.js
@@ -34,15 +34,14 @@ async function graph_explorer (opts) {
let drive_updated_by_search = false // Flag to prevent `onbatch` from re-rendering on search updates.
let is_rendering = false // Flag to prevent concurrent rendering operations in virtual scrolling.
let spacer_element = null // DOM element used to manage scroll position when hubs are toggled.
- let spacer_initial_height = 0
let hub_num = 0 // Counter for expanded hubs.
const el = document.createElement('div')
el.className = 'graph-explorer-wrapper'
const shadow = el.attachShadow({ mode: 'closed' })
shadow.innerHTML = `
-
+
`
const menubar = shadow.querySelector('.menubar')
const container = shadow.querySelector('.graph-container')
@@ -85,32 +84,20 @@ async function graph_explorer (opts) {
******************************************************************************/
async function onbatch (batch) {
// Prevent feedback loops from scroll or toggle actions.
- if (drive_updated_by_scroll) {
- drive_updated_by_scroll = false
- return
- }
- if (drive_updated_by_toggle) {
- drive_updated_by_toggle = false
- return
- }
- if (drive_updated_by_search) {
- drive_updated_by_search = false
- return
- }
+ if (check_and_reset_feedback_flags()) return
for (const { type, paths } of batch) {
- if (!paths || paths.length === 0) continue
+ if (!paths || !paths.length) continue
const data = await Promise.all(
- paths.map(async path => {
- try {
- const file = await drive.get(path)
- if (!file) return null
- return file.raw
- } catch (e) {
- console.error(`Error getting file from drive: ${path}`, e)
- return null
- }
- })
+ paths.map(path =>
+ drive
+ .get(path)
+ .then(file => (file ? file.raw : null))
+ .catch(e => {
+ console.error(`Error getting file from drive: ${path}`, e)
+ return null
+ })
+ )
)
// Call the appropriate handler based on `type`.
const func = on[type]
@@ -123,25 +110,18 @@ async function graph_explorer (opts) {
}
function on_entries ({ data }) {
- if (!data || data[0] === null || data[0] === undefined) {
+ if (!data || data[0] == null) {
console.error('Entries data is missing or empty.')
all_entries = {}
return
}
- try {
- const parsed_data =
- typeof data[0] === 'string' ? JSON.parse(data[0]) : data[0]
- if (typeof parsed_data !== 'object' || parsed_data === null) {
- console.error('Parsed entries data is not a valid object.')
- all_entries = {}
- return
- }
- all_entries = parsed_data
- } catch (e) {
- console.error('Failed to parse entries data:', e)
+ const parsed_data = parse_json_data(data[0], 'entries.json')
+ if (typeof parsed_data !== 'object' || !parsed_data) {
+ console.error('Parsed entries data is not a valid object.')
all_entries = {}
return
}
+ all_entries = parsed_data
// After receiving entries, ensure the root node state is initialized and trigger the first render.
const root_path = '/'
@@ -165,17 +145,11 @@ async function graph_explorer (opts) {
let needs_render = false
const render_nodes_needed = new Set()
- for (let i = 0; i < paths.length; i++) {
- const path = paths[i]
- if (data[i] === null) continue
+ paths.forEach((path, i) => {
+ if (data[i] === null) return
+ const value = parse_json_data(data[i], path)
+ if (value === null) return
- let value
- try {
- value = typeof data[i] === 'string' ? JSON.parse(data[i]) : data[i]
- } catch (e) {
- console.error(`Failed to parse JSON for ${path}:`, e)
- continue
- }
// Handle different runtime state updates based on the path i.e files
switch (true) {
case path.endsWith('node_height.json'):
@@ -187,101 +161,76 @@ async function graph_explorer (opts) {
case path.endsWith('horizontal_scroll_value.json'):
if (typeof value === 'number') horizontal_scroll_value = value
break
- case path.endsWith('selected_instance_paths.json'): {
- const old_paths = [...selected_instance_paths]
- if (Array.isArray(value)) {
- selected_instance_paths = value
- } else {
- console.warn(
- 'selected_instance_paths is not an array, defaulting to empty.',
- value
- )
- selected_instance_paths = []
- }
- const changed_paths = [
- ...new Set([...old_paths, ...selected_instance_paths])
- ]
- changed_paths.forEach(p => render_nodes_needed.add(p))
+ case path.endsWith('selected_instance_paths.json'):
+ selected_instance_paths = process_path_array_update({
+ current_paths: selected_instance_paths,
+ value,
+ render_set: render_nodes_needed,
+ name: 'selected_instance_paths'
+ })
break
- }
- case path.endsWith('confirmed_selected.json'): {
- const old_paths = [...confirmed_instance_paths]
- if (Array.isArray(value)) {
- confirmed_instance_paths = value
- } else {
- console.warn(
- 'confirmed_selected is not an array, defaulting to empty.',
- value
- )
- confirmed_instance_paths = []
- }
- const changed_paths = [
- ...new Set([...old_paths, ...confirmed_instance_paths])
- ]
- changed_paths.forEach(p => render_nodes_needed.add(p))
+ case path.endsWith('confirmed_selected.json'):
+ confirmed_instance_paths = process_path_array_update({
+ current_paths: confirmed_instance_paths,
+ value,
+ render_set: render_nodes_needed,
+ name: 'confirmed_selected'
+ })
break
- }
case path.endsWith('instance_states.json'):
- if (
- typeof value === 'object' &&
- value !== null &&
- !Array.isArray(value)
- ) {
+ if (typeof value === 'object' && value && !Array.isArray(value)) {
instance_states = value
needs_render = true
- } else
+ } else {
console.warn(
'instance_states is not a valid object, ignoring.',
value
)
+ }
break
}
- }
+ })
- if (needs_render) {
- build_and_render_view()
- } else if (render_nodes_needed.size > 0) {
+ if (needs_render) build_and_render_view()
+ else if (render_nodes_needed.size > 0) {
render_nodes_needed.forEach(re_render_node)
}
}
function on_mode ({ data, paths }) {
- let new_current_mode
- let new_previous_mode
- let new_search_query
-
- for (let i = 0; i < paths.length; i++) {
- const path = paths[i]
- const raw_data = data[i]
- if (raw_data === null) continue
- let value
- try {
- value = JSON.parse(raw_data)
- } catch (e) {
- console.error(`Failed to parse JSON for ${path}:`, e)
- continue
- }
+ let new_current_mode, new_previous_mode, new_search_query
+
+ paths.forEach((path, i) => {
+ const value = parse_json_data(data[i], path)
+ if (value === null) return
+
if (path.endsWith('current_mode.json')) new_current_mode = value
else if (path.endsWith('previous_mode.json')) new_previous_mode = value
else if (path.endsWith('search_query.json')) new_search_query = value
- }
+ })
if (typeof new_search_query === 'string') search_query = new_search_query
if (new_previous_mode) previous_mode = new_previous_mode
+
+ if (
+ new_current_mode &&
+ !['default', 'menubar', 'search'].includes(new_current_mode)
+ ) {
+ return void console.warn(
+ `Invalid mode "${new_current_mode}" provided. Ignoring update.`
+ )
+ }
+
if (new_current_mode === 'search' && !search_query) {
search_state_instances = instance_states
}
if (!new_current_mode || mode === new_current_mode) return
- if (mode && new_current_mode === 'search') {
- update_mode_state('previous_mode', mode)
- }
+ if (mode && new_current_mode === 'search') update_drive_state({ dataset: 'mode', name: 'previous_mode', value: mode })
mode = new_current_mode
render_menubar()
handle_mode_change()
- if (mode === 'search' && search_query) {
- perform_search(search_query)
- }
+ if (mode === 'search' && search_query) perform_search(search_query)
}
function inject_style ({ data }) {
@@ -291,20 +240,19 @@ async function graph_explorer (opts) {
}
// Helper to persist component state to the drive.
- async function update_runtime_state (name, value) {
+ async function update_drive_state ({ dataset, name, value }) {
try {
- await drive.put(`runtime/${name}.json`, JSON.stringify(value))
+ await drive.put(`${dataset}/${name}.json`, JSON.stringify(value))
} catch (e) {
- console.error(`Failed to update runtime state for ${name}:`, e)
+ console.error(`Failed to update ${dataset} state for ${name}:`, e)
}
}
- async function update_mode_state (name, value) {
- try {
- await drive.put(`mode/${name}.json`, JSON.stringify(value))
- } catch (e) {
- console.error(`Failed to update mode state for ${name}:`, e)
+ function get_or_create_state (states, instance_path) {
+ if (!states[instance_path]) {
+ states[instance_path] = { expanded_subs: false, expanded_hubs: false }
}
+ return states[instance_path]
}
/******************************************************************************
@@ -314,18 +262,13 @@ async function graph_explorer (opts) {
- `build_view_recursive` creates the flat `view` array from the hierarchical data.
******************************************************************************/
function build_and_render_view (focal_instance_path, hub_toggle = false) {
- if (Object.keys(all_entries).length === 0) {
- console.warn('No entries available to render.')
- return
- }
+ if (Object.keys(all_entries).length === 0) return void console.warn('No entries available to render.')
+
const old_view = [...view]
const old_scroll_top = vertical_scroll_value
const old_scroll_left = horizontal_scroll_value
-
let existing_spacer_height = 0
- if (spacer_element && spacer_element.parentNode) {
- existing_spacer_height = parseFloat(spacer_element.style.height) || 0
- }
+ if (spacer_element && spacer_element.parentNode) existing_spacer_height = parseFloat(spacer_element.style.height) || 0
// Recursively build the new `view` array from the graph data.
view = build_view_recursive({
@@ -339,58 +282,23 @@ async function graph_explorer (opts) {
all_entries
})
- // Calculate the new scroll position to maintain the user's viewport.
- let new_scroll_top = old_scroll_top
- if (focal_instance_path) {
- // If an action was focused on a specific node (like a toggle), try to keep it in the same position.
- const old_toggled_node_index = old_view.findIndex(
- node => node.instance_path === focal_instance_path
- )
- const new_toggled_node_index = view.findIndex(
- node => node.instance_path === focal_instance_path
- )
-
- if (old_toggled_node_index !== -1 && new_toggled_node_index !== -1) {
- const index_change = new_toggled_node_index - old_toggled_node_index
- new_scroll_top = old_scroll_top + index_change * node_height
- }
- } else if (old_view.length > 0) {
- // Otherwise, try to keep the topmost visible node in the same position.
- const old_top_node_index = Math.floor(old_scroll_top / node_height)
- const scroll_offset = old_scroll_top % node_height
- const old_top_node = old_view[old_top_node_index]
- if (old_top_node) {
- const new_top_node_index = view.findIndex(
- node => node.instance_path === old_top_node.instance_path
- )
- if (new_top_node_index !== -1) {
- new_scroll_top = new_top_node_index * node_height + scroll_offset
- }
- }
- }
-
- const render_anchor_index = Math.max(
- 0,
- Math.floor(new_scroll_top / node_height)
- )
+ const new_scroll_top = calculate_new_scroll_top({
+ old_scroll_top,
+ old_view,
+ focal_path: focal_instance_path
+ })
+ const render_anchor_index = Math.max(0, Math.floor(new_scroll_top / node_height))
start_index = Math.max(0, render_anchor_index - chunk_size)
end_index = Math.min(view.length, render_anchor_index + chunk_size)
const fragment = document.createDocumentFragment()
for (let i = start_index; i < end_index; i++) {
if (view[i]) fragment.appendChild(create_node(view[i]))
- else console.warn(`Missing node at index ${i} in view.`)
}
- container.replaceChildren()
- container.appendChild(top_sentinel)
- container.appendChild(fragment)
- container.appendChild(bottom_sentinel)
-
+ container.replaceChildren(top_sentinel, fragment, bottom_sentinel)
top_sentinel.style.height = `${start_index * node_height}px`
- bottom_sentinel.style.height = `${
- (view.length - end_index) * node_height
- }px`
+ bottom_sentinel.style.height = `${(view.length - end_index) * node_height}px`
observer.observe(top_sentinel)
observer.observe(bottom_sentinel)
@@ -401,35 +309,13 @@ async function graph_explorer (opts) {
vertical_scroll_value = container.scrollTop
}
- // Handle the spacer element used for keep entries static wrt cursor by scrolling when hubs are toggled.
- if (hub_toggle || hub_num > 0) {
- spacer_element = document.createElement('div')
- spacer_element.className = 'spacer'
- container.appendChild(spacer_element)
-
- if (hub_toggle) {
- requestAnimationFrame(() => {
- const container_height = container.clientHeight
- const content_height = view.length * node_height
- const max_scroll_top = content_height - container_height
-
- if (new_scroll_top > max_scroll_top) {
- spacer_initial_height = new_scroll_top - max_scroll_top
- spacer_initial_scroll_top = new_scroll_top
- spacer_element.style.height = `${spacer_initial_height}px`
- }
- set_scroll_and_sync()
- })
- } else {
- spacer_element.style.height = `${existing_spacer_height}px`
- requestAnimationFrame(set_scroll_and_sync)
- }
- } else {
- spacer_element = null
- spacer_initial_height = 0
- spacer_initial_scroll_top = 0
- requestAnimationFrame(set_scroll_and_sync)
- }
+ // Handle the spacer element used for keep entries static wrt cursor by scrolling when hubs are toggled.
+ handle_spacer_element({
+ hub_toggle,
+ existing_height: existing_spacer_height,
+ new_scroll_top,
+ sync_fn: set_scroll_and_sync
+ })
}
// Traverses the hierarchical `all_entries` data and builds a flat `view` array for rendering.
@@ -449,16 +335,9 @@ async function graph_explorer (opts) {
const entry = all_entries[base_path]
if (!entry) return []
- if (!instance_states[instance_path]) {
- instance_states[instance_path] = {
- expanded_subs: false,
- expanded_hubs: false
- }
- }
- const state = instance_states[instance_path]
+ const state = get_or_create_state(instance_states, instance_path)
const is_hub_on_top =
- base_path === all_entries[parent_base_path]?.hubs?.[0] ||
- base_path === '/'
+ base_path === all_entries[parent_base_path]?.hubs?.[0] || base_path === '/'
// Calculate the pipe trail for drawing the tree lines. Quite complex logic here.
const children_pipe_trail = [...parent_pipe_trail]
@@ -494,8 +373,8 @@ async function graph_explorer (opts) {
// If hubs are expanded, recursively add them to the view first (they appear above the node).
if (state.expanded_hubs && Array.isArray(entry.hubs)) {
entry.hubs.forEach((hub_path, i, arr) => {
- current_view = current_view.concat(
- build_view_recursive({
+ current_view.push(
+ ...build_view_recursive({
base_path: hub_path,
parent_instance_path: instance_path,
parent_base_path: base_path,
@@ -527,8 +406,8 @@ async function graph_explorer (opts) {
// If subs are expanded, recursively add them to the view (they appear below the node).
if (state.expanded_subs && Array.isArray(entry.subs)) {
entry.subs.forEach((sub_path, i, arr) => {
- current_view = current_view.concat(
- build_view_recursive({
+ current_view.push(
+ ...build_view_recursive({
base_path: sub_path,
parent_instance_path: instance_path,
depth: depth + 1,
@@ -560,13 +439,11 @@ async function graph_explorer (opts) {
is_hub_on_top,
is_search_match,
is_direct_match,
- is_in_original_view
+ is_in_original_view,
+ query
}) {
const entry = all_entries[base_path]
if (!entry) {
- console.error(
- `Entry not found for path: ${base_path}. Cannot create node.`
- )
const err_el = document.createElement('div')
err_el.className = 'node error'
err_el.textContent = `Error: Missing entry for ${base_path}`
@@ -574,138 +451,54 @@ async function graph_explorer (opts) {
}
const states = mode === 'search' ? search_state_instances : instance_states
- let state = states[instance_path]
- if (!state) {
- console.warn(
- `State not found for instance: ${instance_path}. Using default.`
- )
- state = { expanded_subs: false, expanded_hubs: false }
- states[instance_path] = state
- }
-
+ const state = get_or_create_state(states, instance_path)
const el = document.createElement('div')
el.className = `node type-${entry.type || 'unknown'}`
el.dataset.instance_path = instance_path
if (is_search_match) {
el.classList.add('search-result')
- if (is_direct_match) {
- el.classList.add('direct-match')
- }
- if (!is_in_original_view) {
- el.classList.add('new-entry')
- }
+ if (is_direct_match) el.classList.add('direct-match')
+ if (!is_in_original_view) el.classList.add('new-entry')
}
- if (selected_instance_paths.includes(instance_path))
- el.classList.add('selected')
- if (confirmed_instance_paths.includes(instance_path))
- el.classList.add('confirmed')
+ if (selected_instance_paths.includes(instance_path)) el.classList.add('selected')
+ if (confirmed_instance_paths.includes(instance_path)) el.classList.add('confirmed')
const has_hubs = Array.isArray(entry.hubs) && entry.hubs.length > 0
const has_subs = Array.isArray(entry.subs) && entry.subs.length > 0
- if (depth) {
- el.style.paddingLeft = '17.5px'
- }
+ if (depth) el.style.paddingLeft = '17.5px'
el.style.height = `${node_height}px`
- // Handle the special case for the root node since its a bit different.
- if (base_path === '/' && instance_path === '|/') {
- const { expanded_subs } = state
- const prefix_class_name = expanded_subs ? 'tee-down' : 'line-h'
- const prefix_class =
- has_subs && mode !== 'search' ? 'prefix clickable' : 'prefix'
- el.innerHTML = `