From 72a6d6b6fc668387022665ec8d133ab409f197de Mon Sep 17 00:00:00 2001 From: gesinn-it-gea Date: Wed, 1 Oct 2025 16:29:24 +0200 Subject: [PATCH] fix issues from #24 to #27 --- includes/KnowledgeGraph.php | 8 +- .../SpecialKnowledgeGraphDesigner.php | 7 +- resources/KnowledgeGraph.css | 30 ++- resources/KnowledgeGraph.js | 248 +++++++++++++----- resources/KnowledgeGraphFunctions.js | 48 ++++ .../SpecialKnowledgeGraphDesignerTest.php | 6 + 6 files changed, 276 insertions(+), 71 deletions(-) diff --git a/includes/KnowledgeGraph.php b/includes/KnowledgeGraph.php index c1ca188..f8b56f3 100644 --- a/includes/KnowledgeGraph.php +++ b/includes/KnowledgeGraph.php @@ -222,6 +222,7 @@ public static function parserFunctionKnowledgeGraph( Parser $parser, ...$argv ) 'show-property-type' => [ 'false', 'boolean' ], 'properties-panel' => [ 'false', 'boolean' ], 'categories-panel' => [ 'false', 'boolean' ], + 'palette' => [ 'default', 'string' ], ]; self::initSMW(); @@ -279,9 +280,14 @@ public static function parserFunctionKnowledgeGraph( Parser $parser, ...$argv ) $out->setExtensionData( 'knowledgegraphs', self::$graphs ); + $paletteName = $params['palette'] ?? 'default'; + $colors = $GLOBALS['wgKnowledgeGraphColorPalettes'][$paletteName] + ?? $GLOBALS['wgKnowledgeGraphColorPalettes']['default']; + $out->addJsConfigVars( [ 'KnowledgeGraphShowImages' => $GLOBALS['wgKnowledgeGraphShowImages'], - 'KnowledgeGraphDisableCredits' => $GLOBALS['wgKnowledgeGraphDisableCredits'] + 'KnowledgeGraphDisableCredits' => $GLOBALS['wgKnowledgeGraphDisableCredits'], + 'wgKnowledgeGraphColorPalette' => $colors ] ); return [ diff --git a/includes/specials/SpecialKnowledgeGraphDesigner.php b/includes/specials/SpecialKnowledgeGraphDesigner.php index 6431e84..42e615c 100644 --- a/includes/specials/SpecialKnowledgeGraphDesigner.php +++ b/includes/specials/SpecialKnowledgeGraphDesigner.php @@ -60,10 +60,15 @@ public function execute( $par ) { \KnowledgeGraph::$graphs[] = $params; + $paletteName = $params['palette'] ?? 'default'; + $colors = $GLOBALS['wgKnowledgeGraphColorPalettes'][$paletteName] + ?? $GLOBALS['wgKnowledgeGraphColorPalettes']['default']; + $out->addJsConfigVars( [ 'knowledgegraphs' => json_encode( \KnowledgeGraph::$graphs ), 'KnowledgeGraphShowImages' => $GLOBALS['wgKnowledgeGraphShowImages'], - 'KnowledgeGraphDisableCredits' => $GLOBALS['wgKnowledgeGraphDisableCredits'] + 'KnowledgeGraphDisableCredits' => $GLOBALS['wgKnowledgeGraphDisableCredits'], + 'wgKnowledgeGraphColorPalette' => $colors ] ); $out->addHTML( diff --git a/resources/KnowledgeGraph.css b/resources/KnowledgeGraph.css index 1fc6cd9..a3d3dce 100644 --- a/resources/KnowledgeGraph.css +++ b/resources/KnowledgeGraph.css @@ -63,9 +63,29 @@ padding: 4px; } -.custom-menu li.custom-menu-link-entry:hover, -.custom-menu li.custom-menu-property-entry:hover, -.custom-menu li.custom-menu-edge-entry:hover { - background-color: #e0f0ff; - cursor: pointer; +.kg-node-properties-menu li.kg-node-properties-menu-link-entry:hover, +.kg-node-properties-menu li.kg-node-properties-menu-property-entry:hover, +.kg-node-properties-menu li.kg-node-properties-menu-edge-entry:hover { + background-color: #e0f0ff; + cursor: pointer; +} + +.kg-node-properties-menu { + position: absolute; + background: #fff; + border: 1px solid #ccc; + padding: 5px; + list-style: none; + z-index: 10000; + max-height: 300px; + overflow-y: auto; + margin: 0; + box-shadow: 0 4px 10px rgba(0,0,0,0.1); + cursor: pointer; +} + +.kg-node-properties-menu-property-entry-selected { + font-style: italic; + font-weight: bold; + color: #2B7CE9; } diff --git a/resources/KnowledgeGraph.js b/resources/KnowledgeGraph.js index bb33907..6c0dbbc 100644 --- a/resources/KnowledgeGraph.js +++ b/resources/KnowledgeGraph.js @@ -28,38 +28,54 @@ KnowledgeGraph = function () { var LegendDiv; var PropIdPropLabelMap = {}; var nodePropertiesCache = {}; + const colors = mw.config.get('wgKnowledgeGraphColorPalette'); function addLegendEntry(id, label, color) { + if (!LegendDiv) return; if ($(LegendDiv).find('#' + id.replace(/ /g, '_')).length) { return; } - + let fontColor = KnowledgeGraphFunctions.getContrastColor(color); // koristi istu WCAG logiku + if (!fontColor) fontColor = '#000000'; + var container = document.createElement('button'); container.className = 'legend-element-container'; container.classList.add('btn', 'btn-outline-light'); container.id = id.replace(/ /g, '_'); - container.style.color = 'black'; + container.style.color = fontColor; container.style.background = color; container.innerHTML = label; + container.innerHTML = id; container.dataset.active = true; container.dataset.active_color = color; - container.addEventListener('click', (event) => - dispatchEvent_LegendClick(event, id) - ); - LegendDiv.append(container); } + function checkAndToogleId(id) { + return id.trim().replace(/_/g, ' ').replace(/#.*$/, ''); + } + function dispatchEvent_LegendClick(event, id) { var container = $(LegendDiv).find('#' + id.replace(/ /g, '_'))[0]; if (container.dataset.active === 'true') { container.dataset.active = false; container.style.background = '#FFFFFF'; + + let bgColor = container.style.background; + let fontColor = KnowledgeGraphFunctions.getContrastColor(bgColor); + if (!fontColor) fontColor = '#000000'; + container.style.color = fontColor; + } else { container.dataset.active = true; container.style.background = container.dataset.active_color; + + let bgColor = container.style.background; + let fontColor = KnowledgeGraphFunctions.getContrastColor(bgColor); + if (!fontColor) fontColor = '#000000'; + container.style.color = fontColor; } var updateNodes = []; var visited = []; @@ -78,7 +94,7 @@ KnowledgeGraph = function () { var found = false; connectedEdges.forEach((edge) => { - if (edge.to === nodeId) { + if (edge.to === nodeId || edge.from === nodeId) { found = true; } }); @@ -94,7 +110,11 @@ KnowledgeGraph = function () { } Nodes.forEach((node) => { - if (PropIdPropLabelMap[id].indexOf(node.id) !== -1) { + var idValue = checkAndToogleId(node.id); + if (PropIdPropLabelMap[id] === undefined) { + PropIdPropLabelMap[id] = []; + } + if (PropIdPropLabelMap[id].indexOf(idValue) !== -1 || PropIdPropLabelMap[id].indexOf(node.id) !== -1) { updateNodes.push({ id: node.id, hidden: container.dataset.active === 'true' ? false : true, @@ -210,6 +230,23 @@ KnowledgeGraph = function () { return panel.$element.get(0); } + function wrapLabel(text, maxLength) { + const words = text.split(' '); + let wrapped = ''; + let line = ''; + + for (let word of words) { + if ((line + word).length > maxLength) { + if (line) wrapped += line.trim() + '\n'; + line = word + ' '; + } else { + line += word + ' '; + } + } + wrapped += line.trim(); + return wrapped; + } + function addArticleNode(data, label, options, typeID) { if (Nodes.get(label) !== null) { return; @@ -225,7 +262,7 @@ KnowledgeGraph = function () { label: cleanLabel.length <= maxPropValueLength ? cleanLabel - : cleanLabel.substring(0, maxPropValueLength) + '…', + : wrapLabel(cleanLabel, 20), shape: 'box', font: jQuery.extend( {}, @@ -290,19 +327,29 @@ KnowledgeGraph = function () { var property = data[label].properties[i]; if (!(property.canonicalLabel in PropColors)) { - var color_; - function colorExists() { - for (var j in PropColors) { - if (PropColors[j] === color_) { - return true; + if (colors && colors.length > 0) { + // use d3 palette colors defined in wgKnowledgeGraphColorPalette + PropColors[property.canonicalLabel] = KnowledgeGraphFunctions.colorForPropertyLabel( + property.canonicalLabel, + colors, + PropColors + ); + } else { + // use random HSL colors if no palette defined + let color_; + function colorExists() { + for (let j in PropColors) { + if (PropColors[j] === color_) { + return true; + } } + return false; } - return false; + do { + color_ = KnowledgeGraphFunctions.randomHSL(); + } while (colorExists()); + PropColors[property.canonicalLabel] = color_; } - do { - color_ = KnowledgeGraphFunctions.randomHSL(); - } while (colorExists()); - PropColors[property.canonicalLabel] = color_; } var options = @@ -316,7 +363,22 @@ KnowledgeGraph = function () { options = options.nodes; } if (!('color' in options)) { - options.color = PropColors[property.canonicalLabel]; + const nodeColor = PropColors[property.canonicalLabel]; + const textColor = KnowledgeGraphFunctions.getContrastColor(nodeColor); + + options.color = { + background: nodeColor, + border: '#333', + highlight: { + background: nodeColor, + border: '#000' + } + }; + + // ensure readable font color when node background is dark + options.font = Object.assign({}, options.font, { + color: textColor + }); } var legendLabel = @@ -406,7 +468,7 @@ KnowledgeGraph = function () { if (!Nodes.get(valueId)) { const displayLabel = targetLabel.length <= maxPropValueLength ? targetLabel - : targetLabel.substring(0, maxPropValueLength) + '…'; + : wrapLabel(targetLabel, 20); Nodes.add( jQuery.extend({}, options, { @@ -920,23 +982,21 @@ ${propertyOptions}|show-property-type=true } function findNodeIdContaining(labelPart) { - const allNodes = Nodes.get(); - for (let node of allNodes) { - const nodeLabel = node.id.split('#')[0]; - if (nodeLabel === labelPart) { - return node.id; + const allNodes = Nodes.get(); + for (let node of allNodes) { + const nodeLabel = node.id.split('#')[0]; + if (nodeLabel === labelPart) { + return node.id; + } } + return null; } - return null; -} - - function attachContextMenuListener() { Network.on('oncontext', function (params) { params.event.preventDefault(); // close custom menu if exists - $('.custom-menu').hide(); + $('.kg-node-properties-menu').hide(); const pointer = { x: params.pointer.DOM.x, y: params.pointer.DOM.y }; const edgeId = Network.getEdgeAt(pointer); @@ -946,22 +1006,10 @@ ${propertyOptions}|show-property-type=true return; } - // create custom-menu if not exists - let $menu = $('.custom-menu'); + // create kg-node-properties-menu if not exists + let $menu = $('.kg-node-properties-menu'); if (!$menu.length) { - $menu = $('').appendTo('body').hide().css({ - position: 'absolute', - background: '#fff', - border: '1px solid #ccc', - padding: '5px', - listStyle: 'none', - zIndex: 10000, - maxHeight: '300px', - overflowY: 'auto', - margin: 0, - boxShadow: '0 4px 10px rgba(0,0,0,0.1)', - cursor: 'pointer' - }); + $menu = $('').appendTo('body').hide(); } else { $menu.empty(); } @@ -984,7 +1032,7 @@ ${propertyOptions}|show-property-type=true let url = mw.config.get('wgArticlePath').replace('$1', titleLabel); let liLink = document.createElement('li'); - liLink.classList.add('custom-menu-link-entry'); + liLink.classList.add('kg-node-properties-menu-link-entry'); liLink.innerHTML = '🔗 ' + titleLabel; liLink.addEventListener('click', () => window.open(url, '_blank')); $menu.append(liLink); @@ -993,26 +1041,59 @@ ${propertyOptions}|show-property-type=true fetchSemanticDataForNode(nodeId, function (rawProps) { let props = parseProperties(rawProps).filter(p => !p.property.startsWith('_')); nodePropertiesCache[title] = props; + let nodesExisting = Nodes.get(); + let edgesExisting = Edges.get(); if (props.length === 0) { $menu.append('
  • (No available properties)
  • '); } else { props.forEach(p => { let li = document.createElement('li'); - li.classList.add('custom-menu-property-entry'); + li.classList.add('kg-node-properties-menu-property-entry'); li.dataset.action = p.property.replaceAll('_', ' '); li.dataset.direction = p.direction; + let displayName = p.property.replaceAll('_', ' ') + (p.direction === 'inverse' ? ' (inverse)' : ''); + + let expectedLabel = p.direction === 'inverse' + ? '-' + p.property.replaceAll('_', ' ') + : p.property.replaceAll('_', ' '); + + // check if property already exists in graph + let existsInGraph = edgesExisting.some(edge => { + let labelMatch = edge.label === expectedLabel; + let fromMatch = edge.from === title; + let toMatch = edge.to === title; + + if (p.direction === 'direct') { + return labelMatch && fromMatch; + } else if (p.direction === 'inverse') { + return labelMatch && toMatch; + } + return false; + }); + + // Style existing properties differently + if (existsInGraph) { + li.classList.add('kg-node-properties-menu-property-entry-selected'); + } + li.innerHTML = '● ' + displayName; $menu.append(li); }); } // Add click handler for property entries to create nodes and edges - $('.custom-menu li.custom-menu-property-entry').click(function () { + $('.kg-node-properties-menu li.kg-node-properties-menu-property-entry').click(function () { let clickedProperty = $(this).data('action'); let clickedDirection = $(this).data('direction'); - $('.custom-menu').hide(); + $('.kg-node-properties-menu').hide(); + + if ($(this).hasClass('kg-node-properties-menu-property-entry-selected')) { + $(this).removeClass('kg-node-properties-menu-property-entry-selected'); + } else { + $(this).addClass('kg-node-properties-menu-property-entry-selected'); + } let propertyData = getPropertyValueForNode(title, clickedProperty, clickedDirection); @@ -1021,11 +1102,21 @@ ${propertyOptions}|show-property-type=true let propKey = clickedDirection === 'inverse' ? `-${clickedProperty}` : clickedProperty; if (!(propKey in PropColors)) { - let color_; - do { - color_ = KnowledgeGraphFunctions.randomHSL(); - } while (Object.values(PropColors).includes(color_)); - PropColors[propKey] = color_; + if (colors && colors.length > 0) { + // use d3 palette colors defined in wgKnowledgeGraphColorPalette + PropColors[propKey] = KnowledgeGraphFunctions.colorForPropertyLabel( + propKey, + colors, + PropColors + ); + } else { + // use random HSL colors if no palette defined + let color_; + do { + color_ = KnowledgeGraphFunctions.randomHSL(); + } while (Object.values(PropColors).includes(color_)); + PropColors[propKey] = color_; + } } let nodeColor = PropColors[propKey]; @@ -1044,8 +1135,6 @@ ${propertyOptions}|show-property-type=true }; } - let nodesExisting = Nodes.get(); - let edgesExisting = Edges.get(); let keepNode = Network.getNodeAt(pointer); let normalize = str => str.replace(/^-/, ''); @@ -1076,6 +1165,11 @@ ${propertyOptions}|show-property-type=true if (edgeExists) { graphModel.removeEdge(edgeId); + let stillExists = Edges.get().some(e => e.label === edgePropKey); + if (!stillExists) { + $('#' + edgePropKey.replace(/ /g, '_')).remove(); + } + nodesExisting = Nodes.get(); edgesExisting = Edges.get(); @@ -1085,6 +1179,8 @@ ${propertyOptions}|show-property-type=true if (connectedEdges.length === 0) { recursiveDeleteAllChildren(nodeId); + let nodeToClear = nodeId.split('#')[0]; + recursiveDeleteAllChildren(nodeToClear); nodesExisting = Nodes.get(); edgesExisting = Edges.get(); @@ -1143,25 +1239,37 @@ ${propertyOptions}|show-property-type=true } if (!nodesExisting.some(n => n.id === nodeId)) { + let fontColor = KnowledgeGraphFunctions.getContrastColor(nodeColor); + if (!fontColor) fontColor = '#000000'; + let nodeConfig = { id: nodeId, - label: displayLabel, + label: wrapLabel(displayLabel, 20), typeID: typeID, color: nodeColor, + font: jQuery.extend( + {}, + Config.graphOptions.nodes.font, + { + size: Config.graphOptions.nodes.font.size || 30, + color: fontColor, + } + ) }; if (typeID === 9) { nodeConfig.shape = 'box'; - nodeConfig.font = jQuery.extend( - {}, - Config.graphOptions.nodes.font, - { size: Config.graphOptions.nodes.font.size || 30 } - ); if (!Data[nodeId]) { let dataKey = nodeId.split('_')[0]; Data[dataKey] = { properties: [] }; } } + + if (!(edgePropKey in PropIdPropLabelMap)) { + PropIdPropLabelMap[edgePropKey] = []; + } + PropIdPropLabelMap[edgePropKey].push(displayLabel); + graphModel.addNode(nodeConfig); nodesExisting = Nodes.get(); edgesExisting = Edges.get(); @@ -1178,6 +1286,11 @@ ${propertyOptions}|show-property-type=true } graphModel.addEdge(edgeConfig); + + if ($('#' + edgePropKey.replace(/ /g, '_')).length === 0) { + addLegendEntry(edgePropKey, clickedProperty, nodeColor); + } + nodesExisting = Nodes.get(); edgesExisting = Edges.get(); @@ -1199,7 +1312,7 @@ ${propertyOptions}|show-property-type=true let li = document.createElement('li'); let baseUrl = mw.config.get('wgServer') + mw.config.get('wgScriptPath'); let fullUrl = `${baseUrl}/index.php/${propertyTitle}`; - li.classList.add('custom-menu-edge-entry'); + li.classList.add('kg-node-properties-menu-edge-entry'); li.innerHTML = '🔗 ' + cleanedLabel; li.addEventListener('click', () => window.open(fullUrl, '_blank')); @@ -1424,6 +1537,13 @@ ${propertyOptions}|show-property-type=true // $(LegendDiv).height(Config.height); LegendDiv.style.width = Config.width; LegendDiv.style.height = Config.height; + + LegendDiv.addEventListener("click", (e) => { + if (e.target.classList.contains("legend-element-container")) { + let id = e.target.id.replace(/_/g, " "); + dispatchEvent_LegendClick(e, id); + } + }); } createNodes(Config.data); diff --git a/resources/KnowledgeGraphFunctions.js b/resources/KnowledgeGraphFunctions.js index 56d6f23..7d06898 100644 --- a/resources/KnowledgeGraphFunctions.js +++ b/resources/KnowledgeGraphFunctions.js @@ -27,6 +27,52 @@ KnowledgeGraphFunctions = (function () { return 'hsla(' + 360 * h + ',' + '70%,' + '80%,1)'; } + // use d3 palette colors defined in wgKnowledgeGraphColorPalette + function colorForPropertyLabel(label, colors, PropColors = {}) { + if (PropColors[label]) return PropColors[label]; + const index = Object.keys(PropColors).length % colors.length; + PropColors[label] = colors[index]; + return PropColors[label]; + } + + function getContrastColor(hexColor) { + if (!hexColor.startsWith('#')) { + hexColor = rgbToHex(hexColor); + } + + hexColor = hexColor.replace('#', ''); + + let r = parseInt(hexColor.substr(0, 2), 16); + let g = parseInt(hexColor.substr(2, 2), 16); + let b = parseInt(hexColor.substr(4, 2), 16); + + r /= 255; g /= 255; b /= 255; + r = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4); + g = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4); + b = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4); + + let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + const blackLuminance = 0; + + const contrastWithBlack = (Math.max(luminance, blackLuminance) + 0.05) / + (Math.min(luminance, blackLuminance) + 0.05); + + if (contrastWithBlack < 7.5) { + return '#FFFFFF'; + } + + return null; + } + + function rgbToHex(rgb) { + let result = rgb.match(/\d+/g); + if (!result) return '#000000'; + let r = parseInt(result[0]).toString(16).padStart(2, '0'); + let g = parseInt(result[1]).toString(16).padStart(2, '0'); + let b = parseInt(result[2]).toString(16).padStart(2, '0'); + return `#${r}${g}${b}`; + } + function getNestedProp(path, obj) { return path.reduce((xs, x) => (xs && xs[x] ? xs[x] : null), obj); } @@ -54,6 +100,8 @@ KnowledgeGraphFunctions = (function () { isObject, uuidv4, randomHSL, + colorForPropertyLabel, + getContrastColor, getNestedProp, makeEdgeId, makeNodeId diff --git a/tests/phpunit/Unit/specials/SpecialKnowledgeGraphDesignerTest.php b/tests/phpunit/Unit/specials/SpecialKnowledgeGraphDesignerTest.php index 84d63f1..557fbfb 100644 --- a/tests/phpunit/Unit/specials/SpecialKnowledgeGraphDesignerTest.php +++ b/tests/phpunit/Unit/specials/SpecialKnowledgeGraphDesignerTest.php @@ -18,6 +18,11 @@ protected function setUp(): void { parent::setUp(); $this->specialPage = new SpecialKnowledgeGraphDesigner(); + $GLOBALS['wgKnowledgeGraphColorPalettes'] = [ + 'default' => [ '#1f77b4', '#ff7f0e', '#2ca02c' ], + 'pastel' => [ '#aec7e8', '#ffbb78', '#98df8a' ], + ]; + $this->outputPage = $this->getMockBuilder( '\OutputPage' ) ->disableOriginalConstructor() ->getMock(); @@ -110,6 +115,7 @@ public function testExecuteSetsJavaScriptConfigVars() { $this->assertArrayHasKey( 'knowledgegraphs', $jsConfigVars ); $this->assertArrayHasKey( 'KnowledgeGraphShowImages', $jsConfigVars ); $this->assertArrayHasKey( 'KnowledgeGraphDisableCredits', $jsConfigVars ); + $this->assertArrayHasKey( 'wgKnowledgeGraphColorPalette', $jsConfigVars ); $this->assertTrue( $jsConfigVars['KnowledgeGraphShowImages'] ); $this->assertFalse( $jsConfigVars['KnowledgeGraphDisableCredits'] );