diff --git a/core/frontend/filter.js b/core/frontend/filter.js index 24d931a..d37f794 100644 --- a/core/frontend/filter.js +++ b/core/frontend/filter.js @@ -4,17 +4,25 @@ * @copyright GNU GPL 3.0 Cosma's authors */ -import { setNodesDisplaying } from './graph.js'; +import { setNodesDisplaying, updateLinkVisibility } from './graph.js'; window.addEventListener('DOMContentLoaded', () => { + // Node Types Selector /** @type {HTMLFormElement} */ const form = document.getElementById('types-form'); /** @type {HTMLInputElement[]} */ const inputs = form.querySelectorAll('input'); - /** @type {[string, string[]][]} */ const types = Object.entries(typeList); + // Link Type Selector + /** @type {HTMLFormElement} */ + const linkForm = document.getElementById('link-types-form'); + /** @type {HTMLInputElement[]} */ + const linkInputs = linkForm ? linkForm.querySelectorAll('input') : []; + /** @type {[string, {linkKeys: string[], active: boolean, color: string}][]} */ + const linkTypes = Object.entries(linkTypeList); + /** * Default state */ @@ -24,6 +32,12 @@ window.addEventListener('DOMContentLoaded', () => { } changeTypesState(); + for (const [name, { active }] of linkTypes) { + // Check if the element exists before trying to set checked + linkForm.querySelector(`[name="${name}"]`).checked = active; // Use the 'active' flag passed from template.js + } + changeLinkTypesState(); // Initial call for links + /** * Search params state */ @@ -38,11 +52,22 @@ window.addEventListener('DOMContentLoaded', () => { changeTypesState(); } + const linkFiltersFromSearch = searchParams.get('link-filters')?.split('-'); + if (linkFiltersFromSearch?.length) { + for (const [name] of linkTypes) { + linkForm.querySelector(`[name="${name}"]`).checked = linkFiltersFromSearch.includes(name); + } + changeLinkTypesState(); + } + /** * User actions state */ form.addEventListener('change', changeTypesState); + if (linkForm) { + linkForm.addEventListener('change', changeLinkTypesState); // Add listener for link form + } function changeTypesState() { let formState = new FormData(form); @@ -59,6 +84,19 @@ window.addEventListener('DOMContentLoaded', () => { setNodesDisplaying(Array.from(nodeIdsToDisplay)); } + function changeLinkTypesState() { + let formState = new FormData(linkForm); + formState = Object.fromEntries(formState); + + // Get the *names* of the link types that are currently checked + const activeLinkTypes = new Set( + linkTypes.filter(([name]) => !!formState[name]).map(([name]) => name) + ); + + // Call the graph update function with the set of active type names + updateLinkVisibility(activeLinkTypes); + } + let filterNameAltMode; for (const input of inputs) { const { name: filterName, checked: active } = input; @@ -70,30 +108,51 @@ window.addEventListener('DOMContentLoaded', () => { e.preventDefault(); if (filterNameAltMode === filterName) { - displayHidden(); + displayHidden(form); filterNameAltMode = undefined; } else { - hideAllButOne(filterName); + hideAllButOne(form, inputs, filterName); filterNameAltMode = filterName; } } }); } + let linkFilterNameAltMode; + for (const input of linkInputs) { + const { name: filterName } = input; + + input.parentElement.addEventListener('click', (e) => { + const altMode = e.altKey; + if (altMode) { + e.stopPropagation(); + e.preventDefault(); + + if (linkFilterNameAltMode === filterName) { + displayHidden(linkForm); + linkFilterNameAltMode = undefined; + } else { + hideAllButOne(linkForm, linkInputs, filterName); + linkFilterNameAltMode = filterName; + } + } + }); + } + hotkeys('alt+r', (e) => { e.preventDefault(); displayHidden(); }); - function displayHidden() { - form + function displayHidden(targetForm) { + targetForm .querySelectorAll(`input:not(:checked)`) .forEach((checkedInput) => (checkedInput.checked = true)); - form.dispatchEvent(new Event('change')); + targetForm.dispatchEvent(new Event('change')); } - function hideAllButOne(filterName) { - inputs.forEach((input) => (input.checked = filterName === input.name)); - form.dispatchEvent(new Event('change')); + function hideAllButOne(targetForm, targetInputs, filterName) { + targetInputs.forEach((input) => (input.checked = filterName === input.name)); + targetForm.dispatchEvent(new Event('change')); } }); diff --git a/core/frontend/graph.js b/core/frontend/graph.js index d47459f..0b42ef9 100644 --- a/core/frontend/graph.js +++ b/core/frontend/graph.js @@ -87,6 +87,23 @@ simulation.on('tick', function () { .attr('y1', (d) => d.source.y) .attr('x2', (d) => d.target.x) .attr('y2', (d) => d.target.y); + + elts.linkLabels + .attr("x", (d) => (d.source.x + d.target.x) / 2) + .attr("y", (d) => (d.source.y + d.target.y) / 2) + .attr("text-anchor", "middle") + .attr("transform", d => { + var angle = Math.atan2(d.target.y - d.source.y, d.target.x - d.source.x) * 180 / Math.PI; + + // Adjust angle to avoid upside-down text + if (angle > 90 || angle < -90) { + angle = (angle + 180) % 360; + } + + var x = (d.source.x + d.target.x) / 2; + var y = (d.source.y + d.target.y) / 2; + return `rotate(${angle},${x},${y})`; + }); elts.nodes.attr('transform', (d) => 'translate(' + d.x + ',' + d.y + ')'); @@ -104,6 +121,7 @@ const imageFileValidExtnames = new Set(['jpg', 'jpeg', 'png']); /** @type {d3.Selection} */ elts.links = svgSub .append('g') + .attr('class', 'links-group') .selectAll('line') .data(data.edges) .enter() @@ -130,6 +148,15 @@ if (graphProperties.graph_arrows === true) { elts.links.attr('marker-end', 'url(#arrow)'); } +elts.linkLabels = svgSub + .append('g') + .selectAll("text") + .data(data.edges) + .enter() + .append("text") + .attr('font-size', graphProperties.graph_text_size) + .text(d => d.attributes.type); // Assuming each link has a label + const strokeWidth = 2; /** @type {d3.Selection} */ @@ -369,23 +396,25 @@ function generatePathCoordinatesWithBorder(numSegments, diameter, borderSize) { */ function getNodeNetwork(nodeId) { - const edges = graph.edges(nodeId); - const node = elts.nodes.filter(({ key }) => key === nodeId); - const links = elts.links.filter(({ key }) => edges.includes(key)); - - return { - node, - links, - }; + // Links related to this node will be handled by highlight/unlight functions + return { node }; } -function setNodesDisplaying(nodeIds) { - const toDisplay = nodeIds; - const toHide = Array.from(d3.difference(allNodeIds, toDisplay)); +function setNodesDisplaying(nodeIdsToShow) { + const nodesToShowSet = new Set(nodeIdsToShow); + allNodeIds.forEach(nodeId => { + const shouldShow = nodesToShowSet.has(nodeId); + // Use Graphology attribute for node state + graph.setNodeAttribute(nodeId, 'hidden', !shouldShow); + }); - displayNodes(toDisplay); - hideNodes(toHide); + // Update the D3 node elements based on the Graphology attribute + elts.nodes.style('display', d => graph.getNodeAttribute(d.key, 'hidden') ? 'none' : null); + elts.labels.style('display', d => graph.getNodeAttribute(d.key, 'hidden') ? 'none' : null); // Also hide/show labels + + updateLinkVisibilityBasedOnFiltersAndNodes(); + setCounters(); // Update counters after nodes change } graph.on('nodeAttributesUpdated', function ({ key, attributes }) { @@ -393,10 +422,8 @@ graph.on('nodeAttributesUpdated', function ({ key, attributes }) { if (attributes.hidden) { node.node().classList.add('hide'); - links.nodes().forEach((elt) => elt.classList.add('hide')); } else { node.node().classList.remove('hide'); - links.nodes().forEach((elt) => elt.classList.remove('hide')); } }); @@ -419,13 +446,56 @@ function displayNodes(nodeIds) { } function displayNodesAll() { - graph.updateEachNodeAttributes((node, attr) => ({ - ...attr, - hidden: false, - })); + allNodeIds.forEach(nodeId => { + graph.setNodeAttribute(nodeId, 'hidden', false); + }); + elts.nodes.style('display', null); + elts.labels.style('display', null); // Also show labels - elts.nodes.nodes().forEach((elt) => elt.classList.remove('hide')); - elts.links.nodes().forEach((elt) => elt.classList.remove('hide')); + // Update link visibility after showing all nodes + updateLinkVisibilityBasedOnFiltersAndNodes(); + setCounters(); +} + +// --- Manage Link Visibility --- + +// Keep track of which link types are currently active based on checkboxes +let activeLinkTypes = new Set(Object.keys(linkTypeList)); // Initially all active + +// Function called by filter.js when link checkboxes change +function updateLinkVisibility(newActiveLinkTypes) { + activeLinkTypes = newActiveLinkTypes; + updateLinkVisibilityBasedOnFiltersAndNodes(); +} + +// Central function to update link visibility based on *both* filters and node visibility +function updateLinkVisibilityBasedOnFiltersAndNodes() { + console.log("updating link visibility") + if (!linksDisplayToggle) { // Skip if links are globally toggled off + console.log("early return because links toggled off") + elts.links.style('display', 'none'); + elts.linkLabels.style('display', 'none'); + return; + } + + elts.links.style('display', d => { + const typeIsActive = activeLinkTypes.has(d.attributes.type || 'undefined'); + const sourceIsVisible = !graph.getNodeAttribute(d.source.key, 'hidden'); + const targetIsVisible = !graph.getNodeAttribute(d.target.key, 'hidden'); + return typeIsActive && sourceIsVisible && targetIsVisible ? null : 'none'; + }); + + if (!linkLabelsDisplayToggle) { // Skip if labels are globally toggled off + elts.linkLabels.style('display', 'none'); + } else { + elts.linkLabels.style('display', d => { + const typeIsActive = activeLinkTypes.has(d.attributes.type || 'undefined'); + const sourceIsVisible = !graph.getNodeAttribute(d.source.key, 'hidden'); + const targetIsVisible = !graph.getNodeAttribute(d.target.key, 'hidden'); + return typeIsActive && sourceIsVisible && targetIsVisible ? null : 'none'; + }); + } + // Note: No need to update counters for links usually, unless you add a link counter UI element. } let highlightedNodes = []; @@ -441,7 +511,7 @@ function highlightNodes(nodeIds) { .forEach((nodeId) => { const { links, node } = getNodeNetwork(nodeId); node.node().classList.add('highlight'); - links.nodes().forEach((elt) => elt.classList.add('highlight')); + elts.links.nodes().forEach((elt) => elt.classList.add('highlight')); }); highlightedNodes = highlightedNodes.concat(nodeIds); @@ -461,7 +531,7 @@ function unlightNodes() { .forEach((nodeId) => { const { links, node } = getNodeNetwork(nodeId); node.node().classList.remove('highlight'); - links.nodes().forEach((elt) => elt.classList.remove('highlight')); + elts.links.nodes().forEach((elt) => elt.classList.remove('highlight')); }); highlightedNodes = []; @@ -472,12 +542,10 @@ function unlightNodes() { * @param {boolean} isChecked - 'checked' value send by a checkbox input */ +let linksDisplayToggle = true; // Keep track of global link toggle state window.linksDisplayToggle = function (isChecked) { - if (isChecked) { - elts.links.nodes().forEach((elt) => elt.classList.remove('hide')); - } else { - elts.links.nodes().forEach((elt) => elt.classList.add('hide')); - } + linksDisplayToggle = isChecked; + updateLinkVisibilityBasedOnFiltersAndNodes(); // Update visibility when toggled }; /** @@ -493,6 +561,12 @@ window.labelDisplayToggle = function (isChecked) { } }; +let linkLabelsDisplayToggle = true; // Keep track of global label toggle state +window.linkLabelDisplayToggle = function (isChecked) { + linkLabelsDisplayToggle = isChecked; + updateLinkVisibilityBasedOnFiltersAndNodes(); // Update visibility when toggled +}; + /** * Change the font size of graph labels */ @@ -626,10 +700,9 @@ hotkeys('c', (e) => { export { svg, svgSub, - hideNodes, - displayNodes, displayNodesAll, setNodesDisplaying, + updateLinkVisibility, highlightNodes, unlightNodes, translate, diff --git a/core/frontend/view.js b/core/frontend/view.js index c1d40ab..3571a18 100644 --- a/core/frontend/view.js +++ b/core/frontend/view.js @@ -5,14 +5,25 @@ window.addEventListener('DOMContentLoaded', () => { const activeFilters = Array.from(document.querySelectorAll('#types-form input:checked')).map( ({ name }) => name, ); - const activeTags = Array.from(document.querySelectorAll('#tags-form input:checked')).map( - ({ name }) => name, - ); + const linkForm = document.getElementById('link-types-form'); + const activeLinkFilters = linkForm ? Array.from(linkForm.querySelectorAll('input:checked')).map( + ({ name }) => name, + ) : []; + const tagForm = document.getElementById('tags-form'); + const activeTags = tagForm ? Array.from(tagForm.querySelectorAll('input:checked')).map( + ({ name }) => name, + ) : []; const focusLevel = document.getElementById('focus-input').value; if (activeFilters.length > 0) { url.searchParams.set('filters', activeFilters.join('-')); } + + if (activeLinkFilters.length > 0 && activeLinkFilters.length < Object.keys(linkTypeList).length) { + url.searchParams.set('link-filters', activeLinkFilters.join('-')); + } else { + url.searchParams.delete('link-filters'); + } if (activeTags.length > 0) { url.searchParams.set('tags', activeTags.join('-')); } diff --git a/core/i18n.yml b/core/i18n.yml index fd72c5f..a748992 100644 --- a/core/i18n.yml +++ b/core/i18n.yml @@ -22,8 +22,13 @@ left_panel: menu_types: title: - fr: Types - en: Types + fr: Types de Nœuds + en: Node Types + + menu_link_types: + title: + fr: Types de liens + en: Link Types menu_keywords: title: @@ -81,6 +86,9 @@ left_panel: input_labels_show: fr: Afficher les étiquettes en: Display labels + input_link_labels_show: + fr: Afficher les étiquettes liens + en: Display link labels input_highlight_hover_node: fr: Surbrillance au survol en: Highlight on hover diff --git a/core/models/template.js b/core/models/template.js index 613608c..dde35a3 100644 --- a/core/models/template.js +++ b/core/models/template.js @@ -161,6 +161,28 @@ class Template { return arr; }); + const linkFiltersFromGraph = {}; + const allLinkKeys = []; // Keep track of all link keys (Graphology IDs) + + graph.forEachEdge((edgeKey, edgeAttributes, source, target, sourceAttributes, targetAttributes, undirected) => { + const type = edgeAttributes.type || 'undefined'; // Use 'undefined' if no type + allLinkKeys.push(edgeKey); // Store the Graphology key + + if (!linkFiltersFromGraph[type]) { + linkFiltersFromGraph[type] = { + linkKeys: new Set(), // Store Set of Graphology edge keys + active: true, // Default state + color: edgeAttributes?.color || '#e1e1e1' + }; + } + linkFiltersFromGraph[type].linkKeys.add(edgeKey); + }); + + // Convert Sets to Arrays for JSON serialization + for (const type in linkFiltersFromGraph) { + linkFiltersFromGraph[type].linkKeys = Array.from(linkFiltersFromGraph[type].linkKeys); + } + const tagsListAlphabetical = tagsDictAsArrays .map(([name]) => name) .sort((a, b) => a.localeCompare(b)); @@ -361,6 +383,8 @@ class Template { views: views || [], filters: Object.fromEntries(filtersDictAsArrays), tags: Object.fromEntries(tagsDictAsArrays), + linkFilters: linkFiltersFromGraph, + allLinkKeys: allLinkKeys, references: [...references.values()], @@ -401,6 +425,21 @@ class Template { favicon, logo, }); + + let css = ''; + // ... (existing CSS generation for record types) + + // Add CSS variables for link colors + for (const [linkTypeName, linkTypeData] of Object.entries(linkFiltersFromGraph)) { + const slug = slugify(linkTypeName); + // Use the stored color or fallback + const color = linkTypeData.color; + css += `--l_${slug}: ${color};\n`; + // Add CSS for the filter label color (optional but nice) + css += `--n_${slug}: ${color};\n`; + } + // Prepend link type CSS + this.html = this.html.replace('