From 58e3a341aeeb8dbff3e4920e987a0770392a79ab Mon Sep 17 00:00:00 2001 From: kodinkat Date: Tue, 25 Nov 2025 17:14:54 +0000 Subject: [PATCH 1/6] Add generational map tile functionality - Introduced a new tile for displaying a generational map in the groups section. - Added JavaScript for fetching and rendering the generational map. - Updated PHP files to include hooks and display logic for the new tile. - Enhanced the existing tile display logic to accommodate the new 'genmap' tile. - Enqueued necessary scripts and styles for the generational map functionality. --- dt-groups/base-setup.php | 104 ++++++++++++++++++ dt-groups/genmap-tile.js | 228 +++++++++++++++++++++++++++++++++++++++ single-template.php | 2 +- 3 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 dt-groups/genmap-tile.js diff --git a/dt-groups/base-setup.php b/dt-groups/base-setup.php index d05acdda4b..d7279afa57 100644 --- a/dt-groups/base-setup.php +++ b/dt-groups/base-setup.php @@ -27,6 +27,7 @@ public function __construct() { add_filter( 'dt_details_additional_tiles', [ $this, 'dt_details_additional_tiles' ], 10, 2 ); add_filter( 'dt_custom_tiles_after_combine', [ $this, 'dt_custom_tiles_after_combine' ], 10, 2 ); add_action( 'dt_details_additional_section', [ $this, 'dt_details_additional_section' ], 20, 2 ); + add_action( 'dt_record_after_details_section', [ $this, 'dt_record_after_details_section' ], 10, 2 ); add_action( 'wp_enqueue_scripts', [ $this, 'scripts' ], 99 ); // hooks @@ -461,6 +462,7 @@ public function dt_details_additional_section( $section, $post_type ) { self::display_health_metrics_tile( $section, $post_type ); self::display_four_fields_tile( $section, $post_type ); self::display_group_relationships_tile( $section, $post_type ); + // Note: genmap tile is rendered separately via dt_record_after_details_section hook } private function display_health_metrics_tile( $section, $post_type ) { @@ -595,6 +597,56 @@ class="add-new-member"> +
+
+ +
+
+
+
+ +
+

+ + + + +

+
+ +
+
+ " Zúme article on 4 Fields: https://zume.training/four-fields-tool \r\n\r\n" . _x( 'There are 5 squares in the Four Fields diagram. Starting in the top left quadrant and going clockwise and the fifth being in the middle, they stand for:', 'Optional Documentation', 'disciple_tools' ), ]; } + $tiles['genmap'] = [ 'label' => __( 'Generational Map', 'disciple_tools' ) ]; $tiles['groups'] = [ 'label' => __( 'Groups', 'disciple_tools' ) ]; $tiles['other'] = [ 'label' => __( 'Other', 'disciple_tools' ) ]; } @@ -1124,6 +1177,57 @@ public function scripts(){ 'jquery', 'details' ], filemtime( get_theme_file_path() . '/dt-groups/groups.js' ), true ); + + wp_enqueue_script( 'orgchart_js', 'https://cdnjs.cloudflare.com/ajax/libs/orgchart/3.7.0/js/jquery.orgchart.min.js', [ + 'jquery', + ], '3.7.0', true ); + + $css_file_name = 'dt-metrics/common/jquery.orgchart.custom.css'; + $css_uri = get_template_directory_uri() . "/$css_file_name"; + $css_dir = get_template_directory() . "/$css_file_name"; + if ( file_exists( $css_dir ) ) { + wp_enqueue_style( 'orgchart_css', $css_uri, [], filemtime( $css_dir ) ); + } + + $genmap_script_handle = 'dt_groups_genmap'; + wp_enqueue_script( + $genmap_script_handle, + get_template_directory_uri() . '/dt-groups/genmap-tile.js', + [ + 'jquery', + 'orgchart_js', + 'shared-functions', + ], + filemtime( get_theme_file_path() . '/dt-groups/genmap-tile.js' ), + true + ); + + $field_settings = DT_Posts::get_post_field_settings( 'groups' ); + $post_settings = DT_Posts::get_post_settings( 'groups' ); + $status_key = $post_settings['status_field']['status_key'] ?? 'group_status'; + $archived_key = $post_settings['status_field']['archived_key'] ?? ''; + $status_defaults = $field_settings[ $status_key ]['default'] ?? []; + $status_colors = []; + foreach ( $status_defaults as $status_id => $status_option ) { + if ( isset( $status_option['color'] ) ) { + $status_colors[ $status_id ] = $status_option['color']; + } + } + + wp_localize_script( $genmap_script_handle, 'dtGroupGenmap', [ + 'statusField' => [ + 'key' => $status_key, + 'archived_key' => $archived_key, + 'colors' => $status_colors, + ], + 'strings' => [ + 'loading' => __( 'Loading map…', 'disciple_tools' ), + 'error' => __( 'Unable to load generational map.', 'disciple_tools' ), + 'empty' => __( 'No child groups to display.', 'disciple_tools' ), + ], + 'recordUrlBase' => trailingslashit( site_url() ), + 'postType' => $this->post_type, + ] ); } } } diff --git a/dt-groups/genmap-tile.js b/dt-groups/genmap-tile.js new file mode 100644 index 0000000000..bcb62a959c --- /dev/null +++ b/dt-groups/genmap-tile.js @@ -0,0 +1,228 @@ +(function ($) { + 'use strict'; + + const TILE_SELECTOR = '#group-genmap-tile'; + const ENDPOINT = 'metrics/personal/genmap'; + const MAX_CANVAS_HEIGHT = 320; + const MIN_CANVAS_HEIGHT = 220; + const DEFAULT_PAYLOAD = { + p2p_type: 'groups_to_groups', + p2p_direction: 'to', + post_type: 'groups', + gen_depth_limit: 100, + show_archived: false, + data_layers: { + color: 'default-node-color', + layers: [], + show_icons_for_fields: [], + }, + slug: 'personal', + }; + + jQuery(document).ready(() => { + const wrapper = $(TILE_SELECTOR); + if (!wrapper.length) { + return; + } + + const postId = parseInt(wrapper.data('postId'), 10); + if (!postId) { + setMessage(wrapper, 'empty'); + return; + } + + fetchGenmap(wrapper, postId); + }); + + function fetchGenmap(wrapper, focusId) { + setMessage(wrapper, 'loading'); + const payload = { + ...DEFAULT_PAYLOAD, + focus_id: focusId, + }; + + const request = window.makeRequest + ? window.makeRequest('POST', ENDPOINT, payload) + : $.ajax({ + type: 'POST', + url: `${window.wpApiShare?.root || ''}dt/v1/${ENDPOINT}`, + beforeSend: (xhr) => { + if (window.wpApiShare?.nonce) { + xhr.setRequestHeader('X-WP-Nonce', window.wpApiShare.nonce); + } + }, + data: payload, + }); + + const deferred = + typeof request.promise === 'function' ? request.promise() : request; + + deferred + .then((response) => { + const genmap = response?.genmap || null; + if (typeof genmap === 'string' && genmap.includes('No Results')) { + setMessage(wrapper, 'empty'); + return; + } + if (!genmap || !Object.keys(genmap).length) { + setMessage(wrapper, 'empty'); + return; + } + renderChart(wrapper, sanitizeNode(genmap)); + }) + .fail(() => { + setMessage(wrapper, 'error'); + }) + .always(() => { + wrapper.addClass('group-genmap-loaded'); + }); + } + + function renderChart(wrapper, genmap) { + if (!genmap) { + setMessage(wrapper, 'empty'); + return; + } + + const container = wrapper.find('.group-genmap-chart'); + container.empty(); + container + .css({ + overflow: 'auto', + width: '100%', + }) + .removeClass('group-genmap-chart--ready'); + + const orgchart = container.orgchart({ + data: genmap, + nodeContent: 'content', + direction: 'l2r', + createNode: function ($node, data) { + const sharedFlag = String(data.shared ?? '1'); + $node.attr('data-shared', sharedFlag); + + if (data.statusColor) { + $node.css('background-color', data.statusColor); + $node + .find('.title, .content') + .css('background-color', data.statusColor); + $node.find('.content').css('border', '0'); + } + + if (data.isNonShared) { + $node.addClass('group-genmap-node-private'); + } + }, + }); + + // Bind click handler for drill-down navigation. + container.off('click', '.node'); + container.on('click', '.node', function () { + const node = $(this); + if (String(node.data('shared')) === '0') { + return; + } + const nodeId = node.attr('id'); + if (!nodeId) { + return; + } + const urlBase = (window.dtGroupGenmap?.recordUrlBase || '').replace( + /\/$/, + '', + ); + const postType = window.dtGroupGenmap?.postType || 'groups'; + const targetUrl = `${urlBase}/${postType}/${encodeURIComponent(nodeId)}`; + window.open(targetUrl, '_blank', 'noopener'); + }); + + // Keep a handle for potential future use/debugging. + wrapper.data('orgchartInstance', orgchart); + adjustCanvasSize(container); + container.addClass('group-genmap-chart--ready'); + setMessage(wrapper, 'ready'); + } + + function adjustCanvasSize(container) { + const orgchartEl = container.find('.orgchart'); + if (!orgchartEl.length) { + return; + } + + const chartHeight = orgchartEl.outerHeight(true) || MIN_CANVAS_HEIGHT; + const computedHeight = Math.min( + Math.max(chartHeight + 32, MIN_CANVAS_HEIGHT), + MAX_CANVAS_HEIGHT, + ); + container.height(computedHeight); + + const chartWidth = orgchartEl.outerWidth(true); + if (chartWidth > container.innerWidth()) { + const scrollLeft = (chartWidth - container.innerWidth()) / 2; + container.scrollLeft(scrollLeft); + } + } + + function sanitizeNode(node) { + if (!node || typeof node !== 'object') { + return null; + } + + const sanitized = { + ...node, + }; + + sanitized.children = Array.isArray(node.children) + ? node.children + .map((child) => sanitizeNode(child)) + .filter((child) => !!child) + : []; + + const sharedFlag = String(sanitized.shared ?? '1'); + if (sharedFlag === '0') { + sanitized.name = '.......'; + sanitized.content = ''; + sanitized.isNonShared = true; + } + + const archivedKey = window.dtGroupGenmap?.statusField?.archived_key || ''; + const colors = window.dtGroupGenmap?.statusField?.colors || {}; + if (sanitized.status && colors[sanitized.status]) { + sanitized.statusColor = colors[sanitized.status]; + } else if ( + sanitized.status && + archivedKey && + sanitized.status === archivedKey + ) { + sanitized.statusColor = '#808080'; + } + + return sanitized; + } + + function setMessage(wrapper, state) { + const messageEl = wrapper.find('.group-genmap-message'); + if (!messageEl.length) { + return; + } + + switch (state) { + case 'loading': + messageEl.text(getString('loading')).show(); + break; + case 'error': + messageEl.text(getString('error')).show(); + break; + case 'empty': + messageEl.text(getString('empty')).show(); + break; + case 'ready': + default: + messageEl.text('').hide(); + break; + } + } + + function getString(key) { + return window.dtGroupGenmap?.strings?.[key] || ''; + } +})(jQuery); diff --git a/single-template.php b/single-template.php index 9a3f6e5516..523793855f 100644 --- a/single-template.php +++ b/single-template.php @@ -305,7 +305,7 @@ function dt_display_tile( $tile, $post ): bool { $tile_options ){ $class = ''; - if ( in_array( $tile_key, [ 'details', 'status' ] ) ){ + if ( in_array( $tile_key, [ 'details', 'status', 'genmap' ] ) ){ continue; } if ( ( isset( $tile_options['hidden'] ) && $tile_options['hidden'] ) ) { From ebb1688a25839080eebcf29a4f642ac3e914c464 Mon Sep 17 00:00:00 2001 From: kodinkat Date: Mon, 1 Dec 2025 15:31:51 +0000 Subject: [PATCH 2/6] Add group generational map details and selection functionality - Implemented a new footer action for group records to display metrics modals. - Enhanced JavaScript to manage node selection and display detailed information for selected nodes in the generational map. - Added CSS for visual indication of selected nodes. - Updated PHP to ensure correct naming for groups in the generated map. - Introduced modal functionality for adding child groups with appropriate UI elements and event handling. --- dt-groups/base-setup.php | 17 ++ dt-groups/genmap-tile.js | 281 +++++++++++++++++++++++++++++++++- dt-metrics/records/genmap.php | 8 +- 3 files changed, 297 insertions(+), 9 deletions(-) diff --git a/dt-groups/base-setup.php b/dt-groups/base-setup.php index d7279afa57..fe75aa4195 100644 --- a/dt-groups/base-setup.php +++ b/dt-groups/base-setup.php @@ -28,6 +28,7 @@ public function __construct() { add_filter( 'dt_custom_tiles_after_combine', [ $this, 'dt_custom_tiles_after_combine' ], 10, 2 ); add_action( 'dt_details_additional_section', [ $this, 'dt_details_additional_section' ], 20, 2 ); add_action( 'dt_record_after_details_section', [ $this, 'dt_record_after_details_section' ], 10, 2 ); + add_action( 'dt_record_footer', [ $this, 'dt_record_footer' ], 10, 2 ); add_action( 'wp_enqueue_scripts', [ $this, 'scripts' ], 99 ); // hooks @@ -613,6 +614,7 @@ class="group-genmap-tile"
+ __( 'Loading map…', 'disciple_tools' ), 'error' => __( 'Unable to load generational map.', 'disciple_tools' ), 'empty' => __( 'No child groups to display.', 'disciple_tools' ), + 'details' => [ + 'open' => __( 'Open', 'disciple_tools' ), + 'add' => __( 'Add', 'disciple_tools' ), + ], + 'modal' => [ + 'add_child_title' => __( 'Add Child To', 'disciple_tools' ), + 'add_child_name_title' => __( 'Name', 'disciple_tools' ), + 'add_child_but' => __( 'Add Child', 'disciple_tools' ), + ], ], 'recordUrlBase' => trailingslashit( site_url() ), 'postType' => $this->post_type, diff --git a/dt-groups/genmap-tile.js b/dt-groups/genmap-tile.js index bcb62a959c..e89575bef7 100644 --- a/dt-groups/genmap-tile.js +++ b/dt-groups/genmap-tile.js @@ -20,6 +20,19 @@ }; jQuery(document).ready(() => { + // Add CSS for selected node border + if (!document.getElementById('group-genmap-tile-styles')) { + const style = document.createElement('style'); + style.id = 'group-genmap-tile-styles'; + style.textContent = ` + .group-genmap-chart .node.group-genmap-node-selected { + border: 2px dashed rgba(238, 217, 54, 0.8) !important; + box-shadow: 0 0 0 2px rgba(238, 217, 54, 0.3); + } + `; + document.head.appendChild(style); + } + const wrapper = $(TILE_SELECTOR); if (!wrapper.length) { return; @@ -93,10 +106,18 @@ }) .removeClass('group-genmap-chart--ready'); + const nodeTemplate = function (data) { + return ` +
${window.lodash.escape(data.name || '')}
+
${window.lodash.escape(data.content || '')}
+ `; + }; + const orgchart = container.orgchart({ data: genmap, nodeContent: 'content', direction: 'l2r', + nodeTemplate: nodeTemplate, createNode: function ($node, data) { const sharedFlag = String(data.shared ?? '1'); $node.attr('data-shared', sharedFlag); @@ -115,7 +136,7 @@ }, }); - // Bind click handler for drill-down navigation. + // Bind click handler for node selection. container.off('click', '.node'); container.on('click', '.node', function () { const node = $(this); @@ -123,16 +144,17 @@ return; } const nodeId = node.attr('id'); + const parentId = node.data('parent') || 0; if (!nodeId) { return; } - const urlBase = (window.dtGroupGenmap?.recordUrlBase || '').replace( - /\/$/, - '', - ); - const postType = window.dtGroupGenmap?.postType || 'groups'; - const targetUrl = `${urlBase}/${postType}/${encodeURIComponent(nodeId)}`; - window.open(targetUrl, '_blank', 'noopener'); + + // Remove previous selection + container.find('.node').removeClass('group-genmap-node-selected'); + // Add selection to current node + node.addClass('group-genmap-node-selected'); + + openGenmapDetails(wrapper, nodeId, parentId, 'groups'); }); // Keep a handle for potential future use/debugging. @@ -177,6 +199,12 @@ .filter((child) => !!child) : []; + // Ensure name field is set correctly - use fallback if empty + if (!sanitized.name || sanitized.name.trim() === '') { + sanitized.name = + sanitized.title || sanitized.post_title || sanitized.id || ''; + } + const sharedFlag = String(sanitized.shared ?? '1'); if (sharedFlag === '0') { sanitized.name = '.......'; @@ -225,4 +253,241 @@ function getString(key) { return window.dtGroupGenmap?.strings?.[key] || ''; } + + function openGenmapDetails(wrapper, nodeId, parentId, postType) { + const detailsContainer = wrapper.find('#group-genmap-details'); + if (!detailsContainer.length) { + return; + } + + const spinner = ' '; + detailsContainer.html(spinner).show(); + + const request = window.makeRequest + ? window.makeRequest('GET', postType + '/' + nodeId, null, 'dt-posts/v2/') + : $.ajax({ + type: 'GET', + url: `${window.wpApiShare?.root || ''}dt-posts/v2/${postType}/${nodeId}`, + beforeSend: (xhr) => { + if (window.wpApiShare?.nonce) { + xhr.setRequestHeader('X-WP-Nonce', window.wpApiShare.nonce); + } + }, + }); + + const deferred = + typeof request.promise === 'function' ? request.promise() : request; + + deferred + .then((data) => { + if (data) { + renderGenmapDetails(detailsContainer, parentId, postType, data); + } else { + detailsContainer.empty().hide(); + } + }) + .fail(() => { + detailsContainer.html('

' + getString('error') + '

').show(); + }); + } + + function renderGenmapDetails(container, parentId, postType, data) { + const strings = window.dtGroupGenmap?.strings?.details || {}; + const urlBase = (window.dtGroupGenmap?.recordUrlBase || '').replace( + /\/$/, + '', + ); + const postTypeSlug = window.dtGroupGenmap?.postType || 'groups'; + + // Build details line (compact) with close button + let detailsHtml = + '
'; + detailsHtml += + '' + window.lodash.escape(data.title || '') + ''; + + if (data.group_status && data.group_status.label) { + detailsHtml += + 'Status: ' + + window.lodash.escape(data.group_status.label) + + ''; + } + if (data.group_type && data.group_type.label) { + detailsHtml += + 'Type: ' + + window.lodash.escape(data.group_type.label) + + ''; + } + if (data.member_count !== undefined) { + detailsHtml += + 'Members: ' + window.lodash.escape(data.member_count) + ''; + } + if (data.assigned_to && data.assigned_to.display) { + detailsHtml += + 'Assigned: ' + + window.lodash.escape(data.assigned_to.display) + + ''; + } + // Add close button + detailsHtml += + ''; + detailsHtml += '
'; + + // Build buttons line + const openUrl = `${urlBase}/${postTypeSlug}/${encodeURIComponent(data.ID)}`; + detailsHtml += + ''; + + container.html(detailsHtml); + } + + function displayAddChildModal(postType, postId, postName) { + const modalStrings = window.dtGroupGenmap?.strings?.modal || {}; + const listHtml = ` + + + `; + + const buttonsHtml = ``; + + const modal = jQuery('#template_metrics_modal'); + const modalButtons = jQuery('#template_metrics_modal_buttons'); + const title = + (modalStrings.add_child_title || 'Add Child To') + + ` [ ${window.lodash.escape(postName)} ]`; + const content = jQuery('#template_metrics_modal_content'); + + if (!modal.length) { + console.error('Template metrics modal not found'); + return; + } + + jQuery(modalButtons).empty().html(buttonsHtml); + + jQuery('#template_metrics_modal_title') + .empty() + .html(window.lodash.escape(title)); + jQuery(content).css('max-height', '300px'); + jQuery(content).css('overflow', 'auto'); + jQuery(content).empty().html(listHtml); + jQuery(modal).foundation('open'); + } + + function handleAddChild() { + const postType = jQuery('#group_genmap_add_child_post_type').val(); + const parentId = jQuery('#group_genmap_add_child_post_id').val(); + const childTitle = jQuery('#group_genmap_add_child_name').val(); + + if (!postType || !parentId || !childTitle) { + return; + } + + if (window.API && window.API.create_post) { + window.API.create_post(postType, { + title: childTitle, + additional_meta: { + created_from: parentId, + add_connection: 'child_groups', + }, + }) + .then((newPost) => { + jQuery('#template_metrics_modal').foundation('close'); + // Refresh the page to show the new child + window.location.reload(); + }) + .catch(function (error) { + console.error(error); + alert( + 'Error creating child group: ' + (error.message || 'Unknown error'), + ); + }); + } else { + console.error('window.API.create_post is not available'); + } + } + + function closeGenmapDetails(wrapper) { + const detailsContainer = wrapper.find('#group-genmap-details'); + detailsContainer.empty().hide(); + + // Remove selection from all nodes + const container = wrapper.find('.group-genmap-chart'); + container.find('.node').removeClass('group-genmap-node-selected'); + } + + // Event handlers for details buttons + jQuery(document).on('click', '.genmap-details-open', function (e) { + // Link will handle navigation naturally, but we ensure it opens in new tab + // (already handled by target="_blank" in renderGenmapDetails) + }); + + jQuery(document).on('click', '.genmap-details-add-child', function (e) { + e.preventDefault(); + const control = jQuery(e.currentTarget); + displayAddChildModal( + control.data('post_type'), + control.data('post_id'), + control.data('post_name'), + ); + }); + + jQuery(document).on('click', '.group-genmap-details-close', function (e) { + e.preventDefault(); + const wrapper = jQuery(TILE_SELECTOR); + closeGenmapDetails(wrapper); + }); + + jQuery(document).on('click', '#group_genmap_add_child_but', function (e) { + e.preventDefault(); + handleAddChild(); + }); + + jQuery(document).on( + 'open.zf.reveal', + '#template_metrics_modal[data-reveal]', + function () { + jQuery('#group_genmap_add_child_name').focus(); + }, + ); })(jQuery); diff --git a/dt-metrics/records/genmap.php b/dt-metrics/records/genmap.php index bbade50529..e2556d93df 100644 --- a/dt-metrics/records/genmap.php +++ b/dt-metrics/records/genmap.php @@ -98,7 +98,13 @@ public function tree( WP_REST_Request $request ) { // Ensure empty hits on personal based slugs, still ensure user node is accessible. if ( ( $focus_id !== 0 ) && empty( $generated_genmap['children'] ) ) { $generated_genmap['shared'] = 1; - $generated_genmap['name'] = $user->display_name; + // For groups, use the post title; for contacts, use user display name + if ( $post_type === 'groups' ) { + $group_post = get_post( $focus_id ); + $generated_genmap['name'] = $group_post ? $group_post->post_title : $user->display_name; + } else { + $generated_genmap['name'] = $user->display_name; + } } return [ From 780b506cf59259fb570ff826258c72a9cd6d8aec Mon Sep 17 00:00:00 2001 From: kodinkat Date: Wed, 3 Dec 2025 17:19:16 +0000 Subject: [PATCH 3/6] Enhance generational map functionality and layout options - Added a new action to render the generational map after tiles in the single template. - Updated the JavaScript to support layout toggling between vertical and horizontal views, saving user preferences in local storage. - Improved the rendering logic to prevent double rendering of the generational map. - Enhanced CSS for better responsiveness and visual clarity in both layouts. - Introduced a modal for displaying detailed information about selected nodes in the generational map. --- dt-groups/base-setup.php | 57 ++++-- dt-groups/genmap-tile.js | 402 ++++++++++++++++++++++++++++++++++----- single-template.php | 4 + 3 files changed, 395 insertions(+), 68 deletions(-) diff --git a/dt-groups/base-setup.php b/dt-groups/base-setup.php index fe75aa4195..be9ef14b3c 100644 --- a/dt-groups/base-setup.php +++ b/dt-groups/base-setup.php @@ -27,7 +27,7 @@ public function __construct() { add_filter( 'dt_details_additional_tiles', [ $this, 'dt_details_additional_tiles' ], 10, 2 ); add_filter( 'dt_custom_tiles_after_combine', [ $this, 'dt_custom_tiles_after_combine' ], 10, 2 ); add_action( 'dt_details_additional_section', [ $this, 'dt_details_additional_section' ], 20, 2 ); - add_action( 'dt_record_after_details_section', [ $this, 'dt_record_after_details_section' ], 10, 2 ); + add_action( 'dt_record_bottom_after_tiles', [ $this, 'dt_record_bottom_after_tiles' ], 10, 2 ); add_action( 'dt_record_footer', [ $this, 'dt_record_footer' ], 10, 2 ); add_action( 'wp_enqueue_scripts', [ $this, 'scripts' ], 99 ); @@ -614,36 +614,48 @@ class="group-genmap-tile"
- -
-

- - - - -

-
- +
+
+

+ + + + + +

+
+ +
+
+

+
+ +
+ { - // Add CSS for selected node border + // Add CSS for selected node border and truncation if (!document.getElementById('group-genmap-tile-styles')) { const style = document.createElement('style'); style.id = 'group-genmap-tile-styles'; @@ -29,6 +53,69 @@ border: 2px dashed rgba(238, 217, 54, 0.8) !important; box-shadow: 0 0 0 2px rgba(238, 217, 54, 0.3); } + .group-genmap-chart .node .title { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + @media (max-width: 767px) { + .group-genmap-layout-toggle { + display: none !important; + } + } + /* Vertical layout connection line fixes */ + .group-genmap-chart .orgchart.t2b .nodes { + justify-content: center !important; + } + /* Ensure hierarchy containers center their nodes */ + .group-genmap-chart .orgchart.t2b .hierarchy { + text-align: center !important; + } + /* Vertical mode: Set specific left position for horizontal connecting line */ + .group-genmap-chart .orgchart.t2b .hierarchy::before { + border-top: 2px solid var(--dt-blue-o) !important; + left: 40px !important; + width: 100% !important; + } + /* Vertical mode: First child uses specific left position */ + .group-genmap-chart .orgchart.t2b .hierarchy:first-child::before, + .group-genmap-chart .orgchart.t2b .hierarchy.isSiblingsCollapsed.left-sibs::before { + left: 65px !important; + width: calc(50% + 1px) !important; + } + /* Vertical mode: Last child starts from left, extends to center */ + .group-genmap-chart .orgchart.t2b .hierarchy:last-child::before, + .group-genmap-chart .orgchart.t2b .hierarchy.isSiblingsCollapsed.right-sibs::before { + left: 0 !important; + width: calc(50% + 1px) !important; + } + /* Vertical mode: Only child - centered vertical line connecting to parent */ + .group-genmap-chart .orgchart.t2b .hierarchy:not(.hidden):only-child::before { + left: calc(50% - 1px) !important; + width: 2px !important; + border-top: none !important; + border-left: none !important; + border-right: none !important; + border-bottom: 2px solid var(--dt-blue-o) !important; + height: 11px !important; + } + /* Ensure vertical line from parent node is centered */ + .group-genmap-chart .orgchart.t2b .node:not(:only-child)::after { + left: calc(50% - 1px) !important; + } + /* Ensure vertical line to parent node is centered (all levels) */ + .group-genmap-chart .orgchart.t2b ul li ul li > .node::before { + left: calc(50% - 1px) !important; + } + /* Horizontal mode: Revert to base CSS defaults (let base CSS handle it) */ + .group-genmap-chart .orgchart.l2r .hierarchy::before { + left: 0 !important; + } + .group-genmap-chart .orgchart.l2r .hierarchy:first-child::before, + .group-genmap-chart .orgchart.l2r .hierarchy.isSiblingsCollapsed.left-sibs::before { + left: 25px !important; + } `; document.head.appendChild(style); } @@ -38,17 +125,41 @@ return; } + // Initialize layout toggle button + const layoutToggle = $('#group-genmap-layout-toggle'); + const currentLayout = getDefaultLayout(); + + // Show toggle only on desktop + if (!isMobileView()) { + layoutToggle.show(); + updateLayoutToggleIcon(currentLayout); + } + + // Setup layout toggle handler will be set up after functions are defined + const postId = parseInt(wrapper.data('postId'), 10); if (!postId) { setMessage(wrapper, 'empty'); return; } - fetchGenmap(wrapper, postId); + // Store initial layout + wrapper.data('currentLayout', currentLayout); + + // Set initial section width (with delay to ensure DOM is ready) + setTimeout(() => { + updateSectionWidth(currentLayout); + }, 100); + + fetchGenmap(wrapper, postId, currentLayout); }); - function fetchGenmap(wrapper, focusId) { + function fetchGenmap(wrapper, focusId, layout = null) { setMessage(wrapper, 'loading'); + const currentLayout = + layout || wrapper.data('currentLayout') || getDefaultLayout(); + wrapper.data('currentLayout', currentLayout); + const payload = { ...DEFAULT_PAYLOAD, focus_id: focusId, @@ -81,7 +192,12 @@ setMessage(wrapper, 'empty'); return; } - renderChart(wrapper, sanitizeNode(genmap)); + const currentLayout = + wrapper.data('currentLayout') || getDefaultLayout(); + const sanitizedGenmap = sanitizeNode(genmap); + // Store genmap data for layout switching + wrapper.data('currentGenmapData', sanitizedGenmap); + renderChart(wrapper, sanitizedGenmap, currentLayout); }) .fail(() => { setMessage(wrapper, 'error'); @@ -91,12 +207,16 @@ }); } - function renderChart(wrapper, genmap) { + function renderChart(wrapper, genmap, layout = null) { if (!genmap) { setMessage(wrapper, 'empty'); return; } + const currentLayout = + layout || wrapper.data('currentLayout') || getDefaultLayout(); + wrapper.data('currentLayout', currentLayout); + const container = wrapper.find('.group-genmap-chart'); container.empty(); container @@ -108,15 +228,15 @@ const nodeTemplate = function (data) { return ` -
${window.lodash.escape(data.name || '')}
-
${window.lodash.escape(data.content || '')}
+
${window.lodash.escape(data.name || '')}
+
${window.lodash.escape(data.content || '')}
`; }; const orgchart = container.orgchart({ data: genmap, nodeContent: 'content', - direction: 'l2r', + direction: currentLayout, nodeTemplate: nodeTemplate, createNode: function ($node, data) { const sharedFlag = String(data.shared ?? '1'); @@ -162,6 +282,86 @@ adjustCanvasSize(container); container.addClass('group-genmap-chart--ready'); setMessage(wrapper, 'ready'); + + // Update layout toggle icon + updateLayoutToggleIcon(currentLayout); + + // Update section width based on layout + updateSectionWidth(currentLayout); + + // Apply dynamic styles for connection lines after chart renders + applyConnectionLineStyles(container, currentLayout); + + // Recalculate Masonry grid after chart has fully rendered and we have final dimensions + // This ensures the grid positions tiles correctly with the final chart size + setTimeout(() => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + recalculateMasonryGrid(); + }); + }); + }, 300); // Delay to ensure chart is fully rendered and dimensions are final + } + + function applyConnectionLineStyles(container, layout) { + // Use setTimeout to ensure chart is fully rendered before applying styles + setTimeout(() => { + // Remove any existing dynamic style tag + const existingStyleId = 'group-genmap-dynamic-connection-styles'; + const existingStyle = document.getElementById(existingStyleId); + if (existingStyle) { + existingStyle.remove(); + } + + // Create style tag with high specificity targeting the specific chart + const style = document.createElement('style'); + style.id = existingStyleId; + + if (layout === 't2b') { + // Vertical mode: Apply specific pixel values with maximum specificity + style.textContent = ` + #group-genmap-tile .group-genmap-chart .orgchart.t2b .hierarchy::before { + left: 40px !important; + width: 100% !important; + } + #group-genmap-tile .group-genmap-chart .orgchart.t2b .hierarchy:first-child::before, + #group-genmap-tile .group-genmap-chart .orgchart.t2b .hierarchy.isSiblingsCollapsed.left-sibs::before { + left: 65px !important; + width: calc(50% + 1px) !important; + } + #group-genmap-tile .group-genmap-chart .orgchart.t2b .hierarchy:last-child::before, + #group-genmap-tile .group-genmap-chart .orgchart.t2b .hierarchy.isSiblingsCollapsed.right-sibs::before { + left: 0 !important; + width: calc(50% + 1px) !important; + } + /* styles of link lines */ + .orgchart .hierarchy::before { + content: ""; + position: absolute; + top: -11px; /* -(background square size + half width of line) */ + left: 65px !important; + border-top: 2px solid var(--dt-blue-o); + box-sizing: border-box; + } + .orgchart .hierarchy:last-child::before, .orgchart .hierarchy.isSiblingsCollapsed.right-sibs::before { + width: 0 !important; + } + `; + } else { + // Horizontal mode: Revert to defaults + style.textContent = ` + #group-genmap-tile .group-genmap-chart .orgchart.l2r .hierarchy::before { + left: 0 !important; + } + #group-genmap-tile .group-genmap-chart .orgchart.l2r .hierarchy:first-child::before, + #group-genmap-tile .group-genmap-chart .orgchart.l2r .hierarchy.isSiblingsCollapsed.left-sibs::before { + left: 25px !important; + } + `; + } + + document.head.appendChild(style); + }, 100); // Small delay to ensure DOM is ready } function adjustCanvasSize(container) { @@ -254,14 +454,98 @@ return window.dtGroupGenmap?.strings?.[key] || ''; } + function switchLayout(wrapper, newLayout) { + const currentGenmap = wrapper.data('currentGenmapData'); + if (!currentGenmap) { + return; + } + + wrapper.data('currentLayout', newLayout); + saveLayoutPreference(newLayout); + updateLayoutToggleIcon(newLayout); + updateSectionWidth(newLayout); + + // Re-render chart with new layout + renderChart(wrapper, currentGenmap, newLayout); + } + + function updateSectionWidth(layout) { + const genmapSection = jQuery('#genmap'); + if (!genmapSection.length) { + // Retry after a short delay if section doesn't exist yet + setTimeout(() => { + updateSectionWidth(layout); + }, 200); + return; + } + + // Remove existing width classes (but keep custom-tile-section, cell, grid-item) + genmapSection.removeClass('small-12 xlarge-6 large-12 medium-6'); + + if (layout === 't2b') { + // Vertical mode: Use same width as Member List/Church Health + genmapSection.addClass('xlarge-6 large-12 medium-6'); + } else { + // Horizontal mode: Full width (spans entire grid row) + genmapSection.addClass('small-12'); + } + + // Wait for browser to apply CSS changes, then recalculate Masonry grid + requestAnimationFrame(() => { + requestAnimationFrame(() => { + // Double RAF ensures browser has painted the changes + recalculateMasonryGrid(); + }); + }); + } + + function recalculateMasonryGrid() { + // Use the global Masonry grid instance if available, otherwise fallback to selector + if (window.masonGrid && typeof window.masonGrid.masonry === 'function') { + // Force Masonry to recalculate layout + window.masonGrid.masonry('layout'); + } else { + // Fallback: get the Masonry grid instance + const grid = jQuery('.grid'); + if (grid.length && typeof grid.masonry === 'function') { + grid.masonry('layout'); + } + } + } + + function updateLayoutToggleIcon(layout) { + const toggle = $('#group-genmap-layout-toggle'); + if (!toggle.length) { + return; + } + const switchToVerticalIcon = toggle.find( + '.group-genmap-layout-icon-switch-to-vertical', + ); + const switchToHorizontalIcon = toggle.find( + '.group-genmap-layout-icon-switch-to-horizontal', + ); + + // Show icon for the layout we'll switch TO, not the current layout + if (layout === 'l2r') { + // Currently horizontal, show icon to switch to vertical + switchToVerticalIcon.show(); + switchToHorizontalIcon.hide(); + } else { + // Currently vertical, show icon to switch to horizontal + switchToVerticalIcon.hide(); + switchToHorizontalIcon.show(); + } + } + function openGenmapDetails(wrapper, nodeId, parentId, postType) { - const detailsContainer = wrapper.find('#group-genmap-details'); - if (!detailsContainer.length) { + const modal = $('#group-genmap-details-modal'); + if (!modal.length) { return; } const spinner = ' '; - detailsContainer.html(spinner).show(); + $('#group-genmap-details-modal-content').html(spinner); + modal.foundation('open'); const request = window.makeRequest ? window.makeRequest('GET', postType + '/' + nodeId, null, 'dt-posts/v2/') @@ -281,63 +565,68 @@ deferred .then((data) => { if (data) { - renderGenmapDetails(detailsContainer, parentId, postType, data); + renderGenmapDetails(parentId, postType, data); } else { - detailsContainer.empty().hide(); + $('#group-genmap-details-modal-content').html( + '

' + getString('error') + '

', + ); } }) .fail(() => { - detailsContainer.html('

' + getString('error') + '

').show(); + $('#group-genmap-details-modal-content').html( + '

' + getString('error') + '

', + ); }); } - function renderGenmapDetails(container, parentId, postType, data) { + function renderGenmapDetails(parentId, postType, data) { const strings = window.dtGroupGenmap?.strings?.details || {}; const urlBase = (window.dtGroupGenmap?.recordUrlBase || '').replace( /\/$/, '', ); const postTypeSlug = window.dtGroupGenmap?.postType || 'groups'; + const modalContent = $('#group-genmap-details-modal-content'); + const modalTitle = $('#group-genmap-details-modal-title'); - // Build details line (compact) with close button - let detailsHtml = - '
'; - detailsHtml += - '' + window.lodash.escape(data.title || '') + ''; + // Set modal title + modalTitle.html(window.lodash.escape(data.title || '')); + + // Build details content + let detailsHtml = '
'; + detailsHtml += '
'; if (data.group_status && data.group_status.label) { detailsHtml += - 'Status: ' + + '

Status: ' + window.lodash.escape(data.group_status.label) + - ''; + '

'; } if (data.group_type && data.group_type.label) { detailsHtml += - 'Type: ' + + '

Type: ' + window.lodash.escape(data.group_type.label) + - ''; + '

'; } if (data.member_count !== undefined) { detailsHtml += - 'Members: ' + window.lodash.escape(data.member_count) + ''; + '

Members: ' + + window.lodash.escape(data.member_count) + + '

'; } if (data.assigned_to && data.assigned_to.display) { detailsHtml += - 'Assigned: ' + + '

Assigned: ' + window.lodash.escape(data.assigned_to.display) + - ''; + '

'; } - // Add close button - detailsHtml += - ''; - detailsHtml += '
'; + + detailsHtml += '
'; // Build buttons line const openUrl = `${urlBase}/${postTypeSlug}/${encodeURIComponent(data.ID)}`; - detailsHtml += - '
'; + detailsHtml += '
'; + detailsHtml += '
'; detailsHtml += '
$tile_options ){ $class = ''; if ( in_array( $tile_key, [ 'details', 'status', 'genmap' ] ) ){ From 0e4162e905882205b5be8b58307ebd4a893b8864 Mon Sep 17 00:00:00 2001 From: kodinkat Date: Wed, 17 Dec 2025 16:11:05 +0000 Subject: [PATCH 4/6] Enhance D3.js Genmap Visualization - Introduced D3.js for the new generational map visualization, replacing the legacy jQuery OrgChart implementation. - Added support for group type icons and status colors in the D3 visualization. - Implemented layout toggle functionality for responsive design. - Updated SQL queries to include group_type field for groups post type. - Added CSS styles for D3.js visualization and popover interactions. This commit marks a significant step towards modernizing the visualization component and improving user experience. --- dt-groups/base-setup.php | 44 +- dt-groups/genmap-d3.css | 400 +++++++++++ dt-groups/genmap-tile.js | 1265 ++++++++++++++++++++++++++++++++- dt-metrics/records/genmap.php | 21 +- 4 files changed, 1710 insertions(+), 20 deletions(-) create mode 100644 dt-groups/genmap-d3.css diff --git a/dt-groups/base-setup.php b/dt-groups/base-setup.php index a7fa610ea8..22f158864a 100644 --- a/dt-groups/base-setup.php +++ b/dt-groups/base-setup.php @@ -1208,6 +1208,7 @@ public function scripts(){ 'details' ], filemtime( get_theme_file_path() . '/dt-groups/groups.js' ), true ); + // Legacy orgchart library (kept for comparison during migration) wp_enqueue_script( 'orgchart_js', 'https://cdnjs.cloudflare.com/ajax/libs/orgchart/3.7.0/js/jquery.orgchart.min.js', [ 'jquery', ], '3.7.0', true ); @@ -1219,13 +1220,25 @@ public function scripts(){ wp_enqueue_style( 'orgchart_css', $css_uri, [], filemtime( $css_dir ) ); } + // D3.js library for new genmap visualization + wp_enqueue_script( 'd3', 'https://d3js.org/d3.v7.min.js', [], '7.8.5', true ); + + // D3.js genmap styles + $d3_css_file_name = 'dt-groups/genmap-d3.css'; + $d3_css_uri = get_template_directory_uri() . "/$d3_css_file_name"; + $d3_css_dir = get_template_directory() . "/$d3_css_file_name"; + if ( file_exists( $d3_css_dir ) ) { + wp_enqueue_style( 'genmap_d3_css', $d3_css_uri, [], filemtime( $d3_css_dir ) ); + } + $genmap_script_handle = 'dt_groups_genmap'; wp_enqueue_script( $genmap_script_handle, get_template_directory_uri() . '/dt-groups/genmap-tile.js', [ 'jquery', - 'orgchart_js', + 'orgchart_js', // Keep for now during migration + 'd3', // Add D3.js dependency 'shared-functions', ], filemtime( get_theme_file_path() . '/dt-groups/genmap-tile.js' ), @@ -1244,12 +1257,41 @@ public function scripts(){ } } + // Prepare group type icons mapping + $group_type_icons = []; + $group_type_labels = []; + if ( isset( $field_settings['group_type']['default'] ) ) { + foreach ( $field_settings['group_type']['default'] as $type_key => $type_option ) { + $group_type_labels[ $type_key ] = $type_option['label'] ?? ''; + // Map group types to appropriate icons + switch ( $type_key ) { + case 'church': + $group_type_icons[ $type_key ] = get_template_directory_uri() . '/dt-assets/images/circle-square-triangle.svg?v=2'; + break; + case 'group': + $group_type_icons[ $type_key ] = get_template_directory_uri() . '/dt-assets/images/group-type.svg?v=2'; + break; + case 'pre-group': + $group_type_icons[ $type_key ] = get_template_directory_uri() . '/dt-assets/images/group-type.svg?v=2'; + break; + case 'team': + $group_type_icons[ $type_key ] = get_template_directory_uri() . '/dt-assets/images/group-type.svg?v=2'; + break; + default: + $group_type_icons[ $type_key ] = get_template_directory_uri() . '/dt-assets/images/group-type.svg?v=2'; + break; + } + } + } + wp_localize_script( $genmap_script_handle, 'dtGroupGenmap', [ 'statusField' => [ 'key' => $status_key, 'archived_key' => $archived_key, 'colors' => $status_colors, ], + 'groupTypes' => $group_type_labels, + 'groupTypeIcons' => $group_type_icons, 'strings' => [ 'loading' => __( 'Loading map…', 'disciple_tools' ), 'error' => __( 'Unable to load generational map.', 'disciple_tools' ), diff --git a/dt-groups/genmap-d3.css b/dt-groups/genmap-d3.css new file mode 100644 index 0000000000..1f56322ee4 --- /dev/null +++ b/dt-groups/genmap-d3.css @@ -0,0 +1,400 @@ +/** + * D3.js Genmap Visualization Styles + * + * Styles for the new D3.js-based generational map visualization + * replacing the jQuery OrgChart implementation. + */ + +/* SVG Container */ +.group-genmap-chart { + width: 100%; + height: 100%; + min-height: 400px; + position: relative; + overflow: hidden; + background-color: #f9f9f9; + border-radius: 4px; +} + +.group-genmap-chart svg { + width: 100%; + height: 100%; + display: block; +} + +/* Zoom Container */ +.group-genmap-chart .zoom-container { + cursor: move; +} + +/* Links (Tree Edges) */ +.group-genmap-chart .link { + fill: none; + stroke: #999; + stroke-width: 2px; + opacity: 0.6; + transition: opacity 0.2s; +} + +.group-genmap-chart .link:hover { + opacity: 1; + stroke-width: 3px; +} + +/* Nodes */ +.group-genmap-chart .node { + cursor: pointer; +} + +.group-genmap-chart .node rect { + /* fill is set dynamically by D3 based on status color - do not set here */ + stroke: #2a4d6b; + stroke-width: 1px; + rx: 4px; + transition: all 0.2s; +} + +.group-genmap-chart .node:hover rect { + stroke-width: 2px; + stroke: #eed936; + filter: brightness(1.1); +} + +.group-genmap-chart .node.selected rect { + stroke: #eed936; + stroke-width: 2px; + stroke-dasharray: 4, 2; + box-shadow: 0 0 0 2px rgba(238, 217, 54, 0.3); +} + +/* Node Text */ +.group-genmap-chart .node text { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + fill: #333; + pointer-events: none; + text-anchor: middle; + dominant-baseline: middle; +} + +/* Node Title - truncated text */ +.group-genmap-chart .node .node-title { + font-size: 11px; + font-weight: 500; + fill: #333; + max-width: 56px; /* Slightly less than node width for padding */ +} + +/* Node Generation Text */ +.group-genmap-chart .node .node-generation { + font-size: 9px; + fill: #ffffff; /* White for better visibility on colored backgrounds */ + font-weight: 600; + opacity: 0.9; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); /* Subtle shadow for readability */ +} + +/* Node Type Icon */ +.group-genmap-chart .node .node-type-icon { + pointer-events: none; + filter: brightness(0) invert(1); /* Make icon white for visibility */ + opacity: 0.9; +} + +/* Collapse/Expand Indicator - positioned above node */ +.group-genmap-chart .node .node-collapse-group { + pointer-events: all; +} + +.group-genmap-chart .node .node-collapse-indicator { + cursor: pointer; + transition: all 0.2s; +} + +.group-genmap-chart .node .node-collapse-indicator:hover { + fill: #f0f0f0; + stroke: #3f729b; + stroke-width: 2; + transform: scale(1.1); +} + +.group-genmap-chart .node .node-collapse-icon { + pointer-events: none; + user-select: none; +} + +/* Group type icon - positioned above node */ +.group-genmap-chart .node .node-type-icon { + pointer-events: none; +} + +/* Node Icons */ +.group-genmap-chart .node image { + pointer-events: none; +} + +/* Collapse/Expand Indicators */ +.group-genmap-chart .node-collapse-indicator { + font-size: 10px; + fill: #666; + text-anchor: middle; + dominant-baseline: middle; + pointer-events: none; +} + +.group-genmap-chart .node-collapse-count { + font-size: 9px; + fill: #999; + text-anchor: middle; + dominant-baseline: middle; + pointer-events: none; +} + +/* Private/Non-shared Nodes */ +.group-genmap-chart .node.private rect { + fill: #808080; + opacity: 0.6; +} + +.group-genmap-chart .node.private text { + fill: #666; +} + +/* Popover Styles */ +.genmap-popover { + position: absolute; + background: #ffffff; + border: 1px solid #ddd; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 12px; + min-width: 200px; + max-width: 300px; + z-index: 1000; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + font-size: 13px; + line-height: 1.5; + pointer-events: auto; +} + +.genmap-popover::before { + content: ''; + position: absolute; + top: -6px; + left: 20px; + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 6px solid #ffffff; +} + +.genmap-popover::after { + content: ''; + position: absolute; + top: -7px; + left: 20px; + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 6px solid #ddd; +} + +.genmap-popover .popover-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin: 0 0 8px 0; + padding-bottom: 8px; + border-bottom: 1px solid #eee; +} + +.genmap-popover h4 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: #333; + flex: 1; + padding-right: 8px; +} + +.genmap-popover .popover-close { + background: none; + border: none; + font-size: 20px; + line-height: 1; + color: #999; + cursor: pointer; + padding: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s; + flex-shrink: 0; +} + +.genmap-popover .popover-close:hover { + color: #333; +} + +.genmap-popover .popover-field { + margin: 6px 0; + display: flex; + align-items: center; + gap: 8px; +} + +.genmap-popover .popover-field strong { + color: #666; + font-size: 12px; + min-width: 60px; +} + +.genmap-popover .popover-field span { + color: #333; + font-size: 13px; +} + +.genmap-popover .popover-actions { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #eee; + display: flex; + gap: 8px; +} + +.genmap-popover .popover-button { + flex: 1; + padding: 6px 12px; + background: #3f729b; + color: #ffffff; + border: none; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: background 0.2s; +} + +.genmap-popover .popover-button:hover { + background: #2a4d6b; +} + +.genmap-popover .popover-button.secondary { + background: #6c757d; +} + +.genmap-popover .popover-button.secondary:hover { + background: #5a6268; +} + +.genmap-popover .popover-button i { + font-size: 14px; +} + +/* Loading State */ +.group-genmap-chart.loading { + display: flex; + align-items: center; + justify-content: center; +} + +.group-genmap-chart.loading::before { + content: ''; + width: 40px; + height: 40px; + border: 4px solid #f3f3f3; + border-top: 4px solid #3f729b; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Empty State */ +.group-genmap-chart.empty { + display: flex; + align-items: center; + justify-content: center; + color: #999; + font-size: 14px; +} + +/* Mobile Responsive */ +@media (max-width: 767px) { + .group-genmap-chart { + min-height: 300px; + } + + .group-genmap-chart .node text { + font-size: 10px; + } + + .genmap-popover { + min-width: 180px; + max-width: 250px; + font-size: 12px; + padding: 10px; + } + + .genmap-popover .popover-button { + padding: 8px 10px; + font-size: 11px; + } +} + +/* Zoom Controls (Optional) */ +.genmap-zoom-controls { + position: absolute; + top: 10px; + right: 10px; + z-index: 100; + display: flex; + flex-direction: column; + gap: 4px; +} + +.genmap-zoom-controls button { + width: 32px; + height: 32px; + background: #ffffff; + border: 1px solid #ddd; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: #333; + transition: all 0.2s; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.genmap-zoom-controls button:hover { + background: #f5f5f5; + border-color: #3f729b; +} + +.genmap-zoom-controls button:active { + transform: scale(0.95); +} + +/* Accessibility */ +.group-genmap-chart:focus { + outline: 2px solid #3f729b; + outline-offset: 2px; +} + +.group-genmap-chart .node:focus { + outline: 2px solid #eed936; + outline-offset: 2px; +} diff --git a/dt-groups/genmap-tile.js b/dt-groups/genmap-tile.js index 3c6427ef22..d37a8adc13 100644 --- a/dt-groups/genmap-tile.js +++ b/dt-groups/genmap-tile.js @@ -129,7 +129,8 @@ const layoutToggle = $('#group-genmap-layout-toggle'); const currentLayout = getDefaultLayout(); - // Show toggle only on desktop + // Show toggle only on desktop and only for legacy orgchart (not D3) + // D3 visualization will hide it when rendering if (!isMobileView()) { layoutToggle.show(); updateLayoutToggleIcon(currentLayout); @@ -197,7 +198,24 @@ const sanitizedGenmap = sanitizeNode(genmap); // Store genmap data for layout switching wrapper.data('currentGenmapData', sanitizedGenmap); - renderChart(wrapper, sanitizedGenmap, currentLayout); + + // Phase 3: Use D3.js visualization - check stored preference or default + // Store preference in wrapper data so layout toggle can access it + let useD3Visualization = wrapper.data('useD3Visualization'); + if (useD3Visualization === undefined) { + // Default to D3.js visualization + useD3Visualization = true; + wrapper.data('useD3Visualization', useD3Visualization); + } + + // Store current layout for tile size management (even for D3) + wrapper.data('currentLayout', currentLayout); + + if (useD3Visualization && typeof d3 !== 'undefined') { + renderD3Chart(wrapper, sanitizedGenmap); + } else { + renderChart(wrapper, sanitizedGenmap, currentLayout); + } }) .fail(() => { setMessage(wrapper, 'error'); @@ -412,21 +430,1213 @@ sanitized.isNonShared = true; } + // Map status to statusColor - matching legacy flow behavior + // The API returns only 'status' property (e.g., "active"), we need to map it to statusColor + // using dtGroupGenmap.statusField.colors object const archivedKey = window.dtGroupGenmap?.statusField?.archived_key || ''; const colors = window.dtGroupGenmap?.statusField?.colors || {}; - if (sanitized.status && colors[sanitized.status]) { - sanitized.statusColor = colors[sanitized.status]; - } else if ( - sanitized.status && - archivedKey && - sanitized.status === archivedKey - ) { - sanitized.statusColor = '#808080'; + + // Always compute statusColor from status property (matching legacy flow) + // This ensures statusColor is set before data flows to D3 visualization + if (sanitized.status) { + // Direct lookup in colors object - status value should match the key + if (colors[sanitized.status]) { + sanitized.statusColor = colors[sanitized.status]; + } else if (archivedKey && sanitized.status === archivedKey) { + // Handle archived/inactive status with grey color + sanitized.statusColor = '#808080'; + } else { + // Fallback to getStatusColor helper which handles defaults + sanitized.statusColor = getStatusColor(sanitized.status); + } + } else { + // No status provided - use default color + sanitized.statusColor = getStatusColor(null); } return sanitized; } + /** + * Phase 2: Data Transformation Functions for D3.js + * + * These functions transform the API response into D3 hierarchy format + * and add computed properties for visualization. + */ + + /** + * Ellipsize a name to fit within node display + * @param {string} name - The full name + * @param {number} maxLength - Maximum characters (default: 15) + * @returns {string} - Ellipsized name + */ + function ellipsizeName(name, maxLength = 15) { + if (!name || typeof name !== 'string') { + return ''; + } + if (name.length <= maxLength) { + return name; + } + return name.substring(0, maxLength - 3) + '...'; + } + + /** + * Get group type icon path + * @param {string} groupType - The group type key (e.g., 'church', 'group', 'pre-group', 'team') + * @returns {string} - Icon path URL + */ + function getGroupTypeIcon(groupType) { + const icons = window.dtGroupGenmap?.groupTypeIcons || {}; + return icons[groupType] || icons['group'] || ''; + } + + /** + * Get status color for a node + * @param {string} status - The status key + * @returns {string} - Color hex code + */ + function getStatusColor(status) { + const colors = window.dtGroupGenmap?.statusField?.colors || {}; + const archivedKey = window.dtGroupGenmap?.statusField?.archived_key || ''; + + if (status && colors[status]) { + return colors[status]; + } else if (status && archivedKey && status === archivedKey) { + return '#808080'; + } + return '#3f729b'; // Default blue + } + + /** + * Enhance node data with computed properties for D3 visualization + * @param {Object} node - The node data from API + * @param {number} generation - Current generation level (0 = root) + * @returns {Object} - Enhanced node data + */ + function enhanceNodeData(node, generation = 0) { + if (!node || typeof node !== 'object') { + return null; + } + + const enhanced = { + ...node, + // Preserve original data + originalName: node.name || '', + originalStatus: node.status || '', + originalShared: node.shared ?? 1, + + // Computed properties + generation: generation, + collapsed: false, // Default: expanded + _children: null, // For collapse/expand functionality + + // Visual properties + nodeSize: { + width: 60, + height: 40, + }, + + // Display properties + displayName: ellipsizeName(node.name || '', 15), + + // Status and color - ensure statusColor is computed from status + statusColor: + node.statusColor || + (() => { + const colors = window.dtGroupGenmap?.statusField?.colors || {}; + const archivedKey = + window.dtGroupGenmap?.statusField?.archived_key || ''; + if (node.status && colors[node.status]) { + return colors[node.status]; + } else if ( + node.status && + archivedKey && + node.status === archivedKey + ) { + return '#808080'; + } + return getStatusColor(node.status); + })(), + + // Note: iconPath removed - icons will be in popover (Phase 5) + + // Shared/private flag + isNonShared: String(node.shared ?? '1') === '0', + + // Recursively enhance children + children: Array.isArray(node.children) + ? node.children + .map((child) => enhanceNodeData(child, generation + 1)) + .filter((child) => !!child) + : [], + }; + + // If node is non-shared, update display name + if (enhanced.isNonShared) { + enhanced.displayName = '.......'; + enhanced.iconPath = ''; + } + + return enhanced; + } + + /** + * Transform genmap data to D3 hierarchy format + * @param {Object} genmapData - The genmap data from API + * @returns {Object|null} - D3 hierarchy root node or null + */ + function transformToD3Hierarchy(genmapData) { + // Check if D3.js is available + if (typeof d3 === 'undefined' || !d3.hierarchy) { + console.warn('D3.js is not loaded. Cannot transform to D3 hierarchy.'); + return null; + } + + if (!genmapData || typeof genmapData !== 'object') { + return null; + } + + // Enhance the data with computed properties + const enhancedData = enhanceNodeData(genmapData, 0); + + if (!enhancedData) { + return null; + } + + // Create D3 hierarchy + // Note: D3.hierarchy expects children to be in a 'children' property + // Our data already has this structure, so we can use it directly + const root = d3.hierarchy(enhancedData, (d) => { + // Return children array, or null if no children (D3 expects null for leaf nodes) + return d.children && d.children.length > 0 ? d.children : null; + }); + + // Add computed properties to each node in the hierarchy + root.each((node) => { + // Ensure each node has the enhanced properties + if (!node.data.nodeSize) { + node.data.nodeSize = { width: 60, height: 40 }; + } + if (!node.data.displayName) { + node.data.displayName = ellipsizeName(node.data.name || '', 15); + } + // Always ensure statusColor is set - compute from status if missing + // This ensures status colors are properly applied even if sanitizeNode didn't set it + if (!node.data.statusColor) { + if (node.data.status) { + const colors = window.dtGroupGenmap?.statusField?.colors || {}; + const archivedKey = + window.dtGroupGenmap?.statusField?.archived_key || ''; + + if (colors[node.data.status]) { + node.data.statusColor = colors[node.data.status]; + } else if (archivedKey && node.data.status === archivedKey) { + node.data.statusColor = '#808080'; + } else { + node.data.statusColor = getStatusColor(node.data.status); + } + } else { + // No status - use default + node.data.statusColor = getStatusColor(null); + } + } + // Note: iconPath removed - icons will be in popover (Phase 5) + if (node.data.isNonShared) { + node.data.displayName = '.......'; + } + + // Initialize collapse state + if (node.children && node.children.length > 0) { + node.data.collapsed = false; + node.data._children = null; + } + }); + + return root; + } + + /** + * Phase 3: D3 Tree Visualization Core + * + * Functions for creating and rendering the D3.js tree visualization + */ + + /** + * Phase 4: Collapse/Expand Functionality + * + * Functions for collapsing and expanding tree branches + */ + + /** + * Phase 5: Popover Implementation + * + * Functions for displaying lightweight popover with group details, icons, and actions + */ + + /** + * Show popover for a selected node + * @param {jQuery} wrapper - jQuery wrapper element + * @param {Object} nodeData - D3 node data + * @param {Event} event - Click event + */ + function showGenmapPopover(wrapper, nodeData, event) { + // Remove any existing popover + hideGenmapPopover(wrapper); + + // Get container and SVG for positioning + const container = wrapper.find('.group-genmap-chart'); + const svg = wrapper.data('d3Svg'); + if (!container.length || !svg || !svg.node()) { + return; + } + + // Get node position in SVG coordinates + const nodeGroup = d3.select(event.currentTarget); + const transform = nodeGroup.attr('transform'); + const match = transform.match(/translate\(([^,]+),\s*([^)]+)\)/); + if (!match) { + return; + } + + const nodeX = parseFloat(match[2]); // Vertical position + const nodeY = parseFloat(match[1]); // Horizontal position + + // Get SVG bounding box + const svgRect = svg.node().getBoundingClientRect(); + + // Get current zoom transform + const zoomTransform = d3.zoomTransform(svg.node()); + + // Calculate node position in screen coordinates (accounting for zoom/pan) + const screenX = nodeY * zoomTransform.k + zoomTransform.x + svgRect.left; + const screenY = nodeX * zoomTransform.k + zoomTransform.y + svgRect.top; + + // Create popover element + const popover = jQuery('
'); + + // Build popover content + const content = buildPopoverContent(nodeData); + popover.html(content); + + // Append to body for better positioning (relative to viewport) + jQuery('body').append(popover); + + // Get popover dimensions + const popoverWidth = popover.outerWidth() || 250; + const popoverHeight = popover.outerHeight() || 200; + + // Position popover above the node (centered horizontally) + let left = screenX - popoverWidth / 2; + let top = screenY - popoverHeight - 15; // 15px above node + + // Ensure popover stays within viewport bounds + const viewportWidth = jQuery(window).width(); + const viewportHeight = jQuery(window).height(); + const padding = 10; + + // Adjust horizontal position if needed + if (left < padding) { + left = padding; + } else if (left + popoverWidth > viewportWidth - padding) { + left = viewportWidth - popoverWidth - padding; + } + + // Adjust vertical position if needed (show below if not enough space above) + if (top < padding) { + top = screenY + 50; // Show below node instead + } else if (top + popoverHeight > viewportHeight - padding) { + top = viewportHeight - popoverHeight - padding; + } + + popover.css({ + left: left + 'px', + top: top + 'px', + position: 'fixed', // Use fixed positioning relative to viewport + }); + + // Store popover reference + wrapper.data('genmapPopover', popover); + + // Bind event handlers + bindPopoverEvents(wrapper, nodeData, popover); + } + + /** + * Build popover HTML content + * @param {Object} nodeData - D3 node data + * @returns {string} HTML content + */ + function buildPopoverContent(nodeData) { + const data = nodeData.data; + const strings = window.dtGroupGenmap?.strings || {}; + const detailsStrings = strings.details || {}; + const groupTypes = window.dtGroupGenmap?.groupTypes || {}; + const groupTypeIcons = window.dtGroupGenmap?.groupTypeIcons || {}; + const recordUrlBase = window.dtGroupGenmap?.recordUrlBase || ''; + const postType = window.dtGroupGenmap?.postType || 'groups'; + + // Get group type label and icon + const groupType = data.group_type || ''; + const groupTypeLabel = groupTypes[groupType] || groupType || ''; + const groupTypeIcon = groupTypeIcons[groupType] || ''; + + // Get status label (we have status key, but need label - for now use key) + const status = data.status || ''; + + // Build HTML with header containing title and close button + let html = '
'; + html += `

${window.lodash.escape(data.name || '')}

`; + html += ''; + html += '
'; + + // Group type with icon + if (groupTypeLabel) { + html += '
'; + html += 'Type:'; + if (groupTypeIcon) { + html += `${window.lodash.escape(groupTypeLabel)}`; + } else { + html += `${window.lodash.escape(groupTypeLabel)}`; + } + html += '
'; + } + + // Status with color indicator + if (status) { + const statusColor = data.statusColor || '#3f729b'; + html += '
'; + html += 'Status:'; + html += `${window.lodash.escape(status)}`; + html += '
'; + } + + // Generation + if (data.content) { + html += '
'; + html += 'Generation:'; + html += `${window.lodash.escape(data.content)}`; + html += '
'; + } + + // Actions + html += '
'; + + // Open button + const openUrl = `${recordUrlBase}${postType}/${data.id}`; + html += `${window.lodash.escape(detailsStrings.open || 'Open')}`; + + // Add Child button (only if node has children capability) + html += ``; + + // Collapse/Expand button (only if node has children) + if ( + (nodeData.children && nodeData.children.length > 0) || + (nodeData._children && nodeData._children.length > 0) + ) { + const isCollapsed = nodeData.data.collapsed || false; + const collapseText = isCollapsed ? 'Expand' : 'Collapse'; + html += ``; + } + + html += '
'; + + return html; + } + + /** + * Bind event handlers for popover + * @param {jQuery} wrapper - jQuery wrapper element + * @param {Object} nodeData - D3 node data + * @param {jQuery} popover - Popover jQuery element + */ + function bindPopoverEvents(wrapper, nodeData, popover) { + // Close button + popover.on('click', '.popover-close', function (e) { + e.preventDefault(); + e.stopPropagation(); + hideGenmapPopover(wrapper); + }); + + // Add Child button + popover.on('click', '.genmap-popover-add-child', function (e) { + e.preventDefault(); + e.stopPropagation(); + const button = jQuery(this); + displayAddChildModal( + button.data('post-type'), + button.data('post-id'), + button.data('post-name'), + ); + hideGenmapPopover(wrapper); + }); + + // Collapse/Expand button + popover.on('click', '.genmap-popover-collapse', function (e) { + e.preventDefault(); + e.stopPropagation(); + const currentWrapper = jQuery(TILE_SELECTOR); + const treeData = currentWrapper.data('d3Root'); + const linksGroup = currentWrapper.data('d3LinksGroup'); + const nodesGroup = currentWrapper.data('d3NodesGroup'); + const svg = currentWrapper.data('d3Svg'); + const zoomBehavior = currentWrapper.data('d3Zoom'); + const container = currentWrapper.find('.group-genmap-chart'); + const containerWidth = container.width() || 800; + const containerHeight = parseInt(container.css('height')) || 400; + + if (treeData && linksGroup && nodesGroup && svg && zoomBehavior) { + toggleNodeCollapse( + nodeData, + currentWrapper, + treeData, + linksGroup, + nodesGroup, + svg, + zoomBehavior, + containerWidth, + containerHeight, + ); + hideGenmapPopover(currentWrapper); + } + }); + + // Close popover when clicking outside (use setTimeout to avoid immediate closure) + setTimeout(function () { + jQuery(document).one('click', function (e) { + if (popover.length && !popover[0].contains(e.target)) { + // Check if click is not on a node + const clickedNode = jQuery(e.target).closest('.node'); + if (!clickedNode.length) { + hideGenmapPopover(wrapper); + } + } + }); + }, 100); + } + + /** + * Hide popover + * @param {jQuery} wrapper - jQuery wrapper element + */ + function hideGenmapPopover(wrapper) { + const popover = wrapper.data('genmapPopover'); + if (popover) { + popover.remove(); + wrapper.data('genmapPopover', null); + } + } + + /** + * Toggle node collapse/expand state + * @param {Object} node - D3 hierarchy node + * @param {jQuery} wrapper - jQuery wrapper element + * @param {Object} treeData - Current tree data + * @param {Object} linksGroup - D3 links group selection + * @param {Object} nodesGroup - D3 nodes group selection + * @param {Object} svg - D3 SVG selection + * @param {Object} zoomBehavior - D3 zoom behavior + * @param {number} containerWidth - Container width + * @param {number} containerHeight - Container height + */ + function toggleNodeCollapse( + node, + wrapper, + treeData, + linksGroup, + nodesGroup, + svg, + zoomBehavior, + containerWidth, + containerHeight, + ) { + if (!node.children && !node._children) { + return; // No children to collapse/expand + } + + // Toggle collapse state + if (node.children) { + // Collapse: move children to _children + node._children = node.children; + node.children = null; + node.data.collapsed = true; + } else { + // Expand: restore children from _children + node.children = node._children; + node._children = null; + node.data.collapsed = false; + } + + // Update tree layout with new structure + updateD3Tree( + wrapper, + treeData, + linksGroup, + nodesGroup, + svg, + zoomBehavior, + containerWidth, + containerHeight, + ); + } + + /** + * Update D3 tree after collapse/expand + * @param {jQuery} wrapper - jQuery wrapper element + * @param {Object} root - D3 hierarchy root node + * @param {Object} linksGroup - D3 links group selection + * @param {Object} nodesGroup - D3 nodes group selection + * @param {Object} svg - D3 SVG selection + * @param {Object} zoomBehavior - D3 zoom behavior + * @param {number} containerWidth - Container width + * @param {number} containerHeight - Container height + */ + function updateD3Tree( + wrapper, + root, + linksGroup, + nodesGroup, + svg, + zoomBehavior, + containerWidth, + containerHeight, + ) { + // Recreate tree layout with updated structure + const tree = createD3TreeLayout(100, 80); + const treeData = tree(root); + + // Update links + const links = treeData.links(); + const linkPath = d3 + .linkVertical() + .x((d) => d.y) + .y((d) => d.x); + + // Update existing links and add new ones + const linkUpdate = linksGroup.selectAll('.link').data(links, (d) => { + return d.source.data.id + '-' + d.target.data.id; + }); + + // Remove old links + linkUpdate.exit().remove(); + + // Add new links + const linkEnter = linkUpdate + .enter() + .append('path') + .attr('class', 'link') + .attr('fill', 'none') + .attr('stroke', (d) => d.source.data.statusColor || '#999') + .attr('stroke-width', 2) + .attr('opacity', 0.6) + .style('transition', 'opacity 0.2s'); + + // Update all links + linkUpdate.merge(linkEnter).attr('d', linkPath); + + // Update nodes + const nodes = treeData.descendants(); + const nodeUpdate = nodesGroup + .selectAll('.node') + .data(nodes, (d) => d.data.id); + + // Remove old nodes (and their children) + const nodeExit = nodeUpdate.exit(); + nodeExit.selectAll('*').remove(); + nodeExit.remove(); + + // Add new nodes + const nodeEnter = nodeUpdate + .enter() + .append('g') + .attr('class', 'node') + .attr('transform', (d) => `translate(${d.y},${d.x})`); + + // Add rectangle for new nodes + nodeEnter + .append('rect') + .attr('width', 60) + .attr('height', 40) + .attr('x', -30) + .attr('y', -20) + .attr('rx', 4) + .attr('fill', (d) => { + // Ensure statusColor is computed from status property (matching legacy flow) + if (!d.data.statusColor && d.data.status) { + const colors = window.dtGroupGenmap?.statusField?.colors || {}; + const archivedKey = + window.dtGroupGenmap?.statusField?.archived_key || ''; + + if (colors[d.data.status]) { + d.data.statusColor = colors[d.data.status]; + } else if (archivedKey && d.data.status === archivedKey) { + d.data.statusColor = '#808080'; + } else { + d.data.statusColor = getStatusColor(d.data.status); + } + } else if (!d.data.statusColor) { + d.data.statusColor = getStatusColor(null); + } + return d.data.statusColor || '#3f729b'; + }) + .style('fill', (d) => { + // Use style() to ensure it overrides CSS - ensure statusColor is set + if (!d.data.statusColor && d.data.status) { + const colors = window.dtGroupGenmap?.statusField?.colors || {}; + const archivedKey = + window.dtGroupGenmap?.statusField?.archived_key || ''; + + if (colors[d.data.status]) { + d.data.statusColor = colors[d.data.status]; + } else if (archivedKey && d.data.status === archivedKey) { + d.data.statusColor = '#808080'; + } else { + d.data.statusColor = getStatusColor(d.data.status); + } + } else if (!d.data.statusColor) { + d.data.statusColor = getStatusColor(null); + } + return d.data.statusColor || '#3f729b'; + }) + .attr('stroke', (d) => { + // Ensure statusColor is set + if (!d.data.statusColor && d.data.status) { + d.data.statusColor = getStatusColor(d.data.status); + } + return d.data.status === + (window.dtGroupGenmap?.statusField?.archived_key || 'inactive') + ? '#666' + : '#2a4d6b'; + }) + .attr('stroke-width', 1); + + // Note: Group type icons and collapse/expand buttons removed - will be shown in popover (Phase 5) + + // Add text group for new nodes + const textGroupEnter = nodeEnter + .append('g') + .attr('class', 'node-text-group') + .attr('clip-path', 'url(#node-text-clip)'); + + textGroupEnter + .append('text') + .attr('x', 0) + .attr('y', -8) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('font-size', '10px') + .attr('font-weight', '500') + .attr('fill', '#333') + .attr('class', 'node-title') + .text((d) => { + const name = d.data.displayName || ''; + return name.length > 10 ? name.substring(0, 7) + '...' : name; + }); + + textGroupEnter + .append('text') + .attr('x', 0) + .attr('y', 8) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('font-size', '9px') + .attr('fill', '#ffffff') + .attr('font-weight', '600') + .attr('class', 'node-generation') + .attr('opacity', 0.9) + .text((d) => { + if (d.data.content && d.data.content.startsWith('Gen ')) { + return d.data.content; + } + const gen = d.data.generation !== undefined ? d.data.generation : 0; + return `Gen ${gen}`; + }); + + // Update existing nodes (position and collapse indicator) + const nodeUpdateMerged = nodeUpdate.merge(nodeEnter); + + // Update node positions with transition + nodeUpdateMerged + .transition() + .duration(300) + .attr('transform', (d) => `translate(${d.y},${d.x})`); + + // Note: Collapse indicators removed - will be in popover (Phase 5) + + // Update link positions with transition + linkUpdate.merge(linkEnter).transition().duration(300).attr('d', linkPath); + + // Re-bind click handlers for node selection and popover + nodeUpdateMerged.on('click', function (event, d) { + if (d.data.isNonShared) { + return; + } + + nodesGroup.selectAll('.node').classed('selected', false); + d3.select(this).classed('selected', true); + wrapper.data('selectedNode', d); + + // Show popover + showGenmapPopover(wrapper, d, event); + }); + + // Store updated tree data + wrapper.data('d3Root', treeData); + } + + /** + * Setup SVG container for D3 visualization + * @param {jQuery} container - jQuery element for the chart container + * @param {string} layout - Current layout ('t2b' for vertical/smaller tile, 'l2r' for horizontal/larger tile) + * @returns {Object} - Object with svg, zoomContainer, linksGroup, nodesGroup + */ + function setupSVGContainer(container, layout = 'l2r') { + // Determine height based on layout/tile size + // Smaller tile (t2b/medium-6): taller height for better vertical space utilization + // Larger tile (l2r/small-12): standard height + const baseHeight = layout === 't2b' ? 600 : 400; + + // Ensure container has proper styling + container.css({ + width: '100%', + minHeight: baseHeight + 'px', + height: baseHeight + 'px', + position: 'relative', + overflow: 'hidden', + background: '#f9f9f9', + }); + + // Get container dimensions - use parent width if container doesn't have explicit width + const parentWidth = container.parent().width() || window.innerWidth; + const containerWidth = container.width() || parentWidth; + const containerHeight = baseHeight; + + // Clear container + container.empty(); + container.addClass('group-genmap-chart--d3'); + + // Create SVG element + const svg = d3 + .select(container[0]) + .append('svg') + .attr('width', containerWidth) + .attr('height', containerHeight) + .attr('class', 'genmap-svg') + .style('display', 'block') + .style('cursor', 'move'); + + // Create zoom container (g element that will be transformed) + const zoomContainer = svg.append('g').attr('class', 'zoom-container'); + + // Create groups for links and nodes (order matters - links should be behind nodes) + const linksGroup = zoomContainer.append('g').attr('class', 'links'); + const nodesGroup = zoomContainer.append('g').attr('class', 'nodes'); + + return { + svg, + zoomContainer, + linksGroup, + nodesGroup, + containerWidth, + containerHeight, + }; + } + + /** + * Setup zoom and pan functionality + * @param {Object} svg - D3 SVG selection + * @param {Object} zoomContainer - D3 zoom container selection + * @param {Function} onZoom - Callback function when zoom/pan occurs + * @returns {Object} - D3 zoom behavior + */ + function setupZoom(svg, zoomContainer, onZoom) { + const zoom = d3 + .zoom() + .scaleExtent([0.1, 3]) // Min zoom: 10%, Max zoom: 300% + .filter((event) => { + // Allow zoom/pan on mouse wheel, drag, and touch + // Prevent zoom on double-click + if (event.type === 'dblclick') { + return false; + } + // Allow all other interactions + return true; + }) + .on('zoom', (event) => { + // Apply transform to zoom container + zoomContainer.attr('transform', event.transform); + + // Close popover on zoom/pan + const wrapper = jQuery(TILE_SELECTOR); + if (wrapper.length) { + hideGenmapPopover(wrapper); + } + + if (onZoom) { + onZoom(event); + } + }); + + // Apply zoom behavior to SVG + // D3-zoom automatically handles: + // - Mouse wheel for zoom + // - Mouse drag for pan + // - Touch pinch for zoom (mobile) + // - Touch drag for pan (mobile) + svg.call(zoom); + + return zoom; + } + + /** + * Auto-fit tree to viewport + * @param {Object} svg - D3 SVG selection + * @param {Object} zoom - D3 zoom behavior + * @param {Object} root - D3 hierarchy root node + * @param {number} containerWidth - Container width + * @param {number} containerHeight - Container height + * @param {number} nodeWidth - Horizontal spacing between nodes + * @param {number} nodeHeight - Vertical spacing between nodes + */ + function fitToViewport( + svg, + zoom, + root, + containerWidth, + containerHeight, + nodeWidth = 100, + nodeHeight = 80, + ) { + if (!root || !root.descendants().length) { + return; + } + + // Calculate tree bounds + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + + root.descendants().forEach((d) => { + if (d.x < minX) minX = d.x; + if (d.x > maxX) maxX = d.x; + if (d.y < minY) minY = d.y; + if (d.y > maxY) maxY = d.y; + }); + + // Calculate tree dimensions + const treeWidth = maxY - minY; + const treeHeight = maxX - minX; + + // Add padding + const padding = 40; + const targetWidth = containerWidth - padding * 2; + const targetHeight = containerHeight - padding * 2; + + // Calculate scale to fit + const scaleX = targetWidth / treeWidth; + const scaleY = targetHeight / treeHeight; + const scale = Math.min(scaleX, scaleY, 1); // Don't zoom in beyond 100% + + // Calculate translation to center + const translateX = (containerWidth - treeWidth * scale) / 2 - minY * scale; + const translateY = padding - minX * scale; + + // Apply transform + svg.call( + zoom.transform, + d3.zoomIdentity.translate(translateX, translateY).scale(scale), + ); + } + + /** + * Create D3 tree layout + * @param {number} nodeWidth - Horizontal spacing between nodes (default: 100) + * @param {number} nodeHeight - Vertical spacing between nodes (default: 80) + * @returns {Object} - D3 tree layout + */ + function createD3TreeLayout(nodeWidth = 100, nodeHeight = 80) { + const tree = d3 + .tree() + .nodeSize([nodeHeight, nodeWidth]) // [height, width] for vertical layout + .separation((a, b) => { + // Separation function: siblings get 1, different parents get more space + return a.parent === b.parent ? 1 : 1.2; + }); + + return tree; + } + + /** + * Render D3 tree visualization + * @param {jQuery} wrapper - jQuery wrapper element + * @param {Object} genmapData - Genmap data from API + */ + function renderD3Chart(wrapper, genmapData) { + // Check if D3.js is available + if (typeof d3 === 'undefined') { + console.error('D3.js is not loaded. Cannot render D3 chart.'); + setMessage(wrapper, 'error'); + return; + } + + if (!genmapData || typeof genmapData !== 'object') { + setMessage(wrapper, 'empty'); + return; + } + + // Transform data to D3 hierarchy + const root = transformToD3Hierarchy(genmapData); + if (!root) { + setMessage(wrapper, 'error'); + return; + } + + // Get container + const container = wrapper.find('.group-genmap-chart'); + if (!container.length) { + setMessage(wrapper, 'error'); + return; + } + + // Get current layout for determining container height + const currentLayout = wrapper.data('currentLayout') || getDefaultLayout(); + + // Setup SVG container with layout-aware height + const { + svg, + zoomContainer, + linksGroup, + nodesGroup, + containerWidth, + containerHeight, + } = setupSVGContainer(container, currentLayout); + + // Create tree layout + const tree = createD3TreeLayout(100, 80); // 100px horizontal, 80px vertical spacing + const treeData = tree(root); + + // Setup zoom and pan + const zoomBehavior = setupZoom(svg, zoomContainer); + + // Add clipping path for text overflow protection (create once per SVG) + const defs = svg.append('defs'); + defs + .append('clipPath') + .attr('id', 'node-text-clip') + .append('rect') + .attr('x', -28) // Leave 2px padding on each side (60px width - 4px = 56px) + .attr('y', -18) // Leave 2px padding on top/bottom (40px height - 4px = 36px) + .attr('width', 56) + .attr('height', 36); + + // Store references for later use (collapse/expand, popover, etc.) + wrapper.data('d3Svg', svg); + wrapper.data('d3Zoom', zoomBehavior); + wrapper.data('d3Root', treeData); + wrapper.data('d3LinksGroup', linksGroup); + wrapper.data('d3NodesGroup', nodesGroup); + wrapper.data('d3ZoomContainer', zoomContainer); + + // Render links (edges) - Phase 4 will implement full rendering + // D3 v7 API: Use d3.link() with curve for curved paths + // For vertical layout, we use linkVertical() without curve, or link() with proper curve + const links = treeData.links(); + + // Create link generator with curve for smooth paths + const linkPath = d3 + .linkVertical() + .x((d) => d.y) // Horizontal position + .y((d) => d.x); // Vertical position + + // Render links with enhanced styling + linksGroup + .selectAll('.link') + .data(links) + .enter() + .append('path') + .attr('class', 'link') + .attr('d', linkPath) + .attr('fill', 'none') + .attr('stroke', (d) => { + // Use parent node's status color for link, or default gray + return d.source.data.statusColor || '#999'; + }) + .attr('stroke-width', 2) + .attr('opacity', 0.6) + .style('transition', 'opacity 0.2s'); + + // Render nodes - Phase 4 will implement full node rendering + // For now, we'll create basic nodes + const nodes = treeData.descendants(); + const nodeGroup = nodesGroup + .selectAll('.node') + .data(nodes, (d) => d.data.id) + .enter() + .append('g') + .attr('class', 'node') + .attr('transform', (d) => `translate(${d.y},${d.x})`); + + // Add node rectangle with status color + nodeGroup + .append('rect') + .attr('width', 60) + .attr('height', 40) + .attr('x', -30) // Center horizontally + .attr('y', -20) // Center vertically + .attr('rx', 4) + .attr('fill', (d) => { + // statusColor should already be set by sanitizeNode, but ensure it's computed if missing + // This is a safety check - statusColor should be set during data sanitization + if (!d.data.statusColor) { + if (d.data.status) { + const colors = window.dtGroupGenmap?.statusField?.colors || {}; + const archivedKey = + window.dtGroupGenmap?.statusField?.archived_key || ''; + + if (colors[d.data.status]) { + d.data.statusColor = colors[d.data.status]; + } else if (archivedKey && d.data.status === archivedKey) { + d.data.statusColor = '#808080'; + } else { + d.data.statusColor = getStatusColor(d.data.status); + } + } else { + d.data.statusColor = getStatusColor(null); + } + } + + // Use computed status color, fallback to default blue + return d.data.statusColor || '#3f729b'; + }) + .style('fill', (d) => { + // Use style() instead of attr() to ensure it overrides CSS + // statusColor should already be set, but ensure it's computed if missing + if (!d.data.statusColor && d.data.status) { + const colors = window.dtGroupGenmap?.statusField?.colors || {}; + const archivedKey = + window.dtGroupGenmap?.statusField?.archived_key || ''; + + if (colors[d.data.status]) { + d.data.statusColor = colors[d.data.status]; + } else if (archivedKey && d.data.status === archivedKey) { + d.data.statusColor = '#808080'; + } else { + d.data.statusColor = getStatusColor(d.data.status); + } + } + return d.data.statusColor || '#3f729b'; + }) + .attr('stroke', (d) => { + // Ensure statusColor is set + if (!d.data.statusColor && d.data.status) { + d.data.statusColor = getStatusColor(d.data.status); + } + const color = d.data.statusColor || '#3f729b'; + // Lighten stroke for archived/inactive + return d.data.status === + (window.dtGroupGenmap?.statusField?.archived_key || 'inactive') + ? '#666' + : '#2a4d6b'; + }) + .attr('stroke-width', 1); + + // Note: Group type icons and collapse/expand buttons removed - will be shown in popover (Phase 5) + + // Add node text container with clipping for proper text truncation + const textGroup = nodeGroup + .append('g') + .attr('class', 'node-text-group') + .attr('clip-path', 'url(#node-text-clip)'); + + // Add node title (ellipsized name) - with padding from edges + textGroup + .append('text') + .attr('x', 0) + .attr('y', -8) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('font-size', '10px') // Slightly smaller to ensure fit + .attr('font-weight', '500') + .attr('fill', '#333') + .attr('class', 'node-title') + .text((d) => { + // Use displayName which is already ellipsized, but ensure it fits with padding + const name = d.data.displayName || ''; + // Truncate to 10 chars max to ensure fit with 2px padding on each side + if (name.length > 10) { + return name.substring(0, 7) + '...'; + } + return name; + }); + + // Add generation indicator (Gen X) - improved visibility + textGroup + .append('text') + .attr('x', 0) + .attr('y', 8) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('font-size', '9px') + .attr('fill', '#ffffff') // White for better visibility on colored backgrounds + .attr('font-weight', '600') // Slightly bolder + .attr('class', 'node-generation') + .attr('opacity', 0.9) // Slight transparency for subtle effect + .text((d) => { + // Use content field if available (e.g., "Gen 0", "Gen 1"), otherwise calculate from generation + if (d.data.content && d.data.content.startsWith('Gen ')) { + return d.data.content; + } + const gen = d.data.generation !== undefined ? d.data.generation : 0; + return `Gen ${gen}`; + }); + + // Add click handler for node selection and popover display + nodeGroup.on('click', function (event, d) { + // Prevent clicks on non-shared nodes + if (d.data.isNonShared) { + return; + } + + // Regular node click for selection/popover + // Remove previous selection + nodesGroup.selectAll('.node').classed('selected', false); + + // Add selection to current node + d3.select(this).classed('selected', true); + + // Store selected node for popover + wrapper.data('selectedNode', d); + + // Show popover + showGenmapPopover(wrapper, d, event); + }); + + // Auto-fit to viewport + fitToViewport( + svg, + zoomBehavior, + treeData, + containerWidth, + containerHeight, + 100, + 80, + ); + + // Show layout toggle button and update icon based on current layout + const layoutToggle = $('#group-genmap-layout-toggle'); + if (layoutToggle.length && !isMobileView()) { + layoutToggle.show(); + const currentLayout = wrapper.data('currentLayout') || getDefaultLayout(); + updateLayoutToggleIcon(currentLayout); + // Ensure section width matches layout + updateSectionWidth(currentLayout); + } + + // Mark as ready + container.addClass('group-genmap-chart--ready'); + setMessage(wrapper, 'ready'); + } + function setMessage(wrapper, state) { const messageEl = wrapper.find('.group-genmap-message'); if (!messageEl.length) { @@ -531,7 +1741,7 @@ switchToVerticalIcon.show(); switchToHorizontalIcon.hide(); } else { - // Currently vertical, show icon to switch to horizontal + // Currently vertical (t2b), show icon to switch to horizontal switchToVerticalIcon.hide(); switchToHorizontalIcon.show(); } @@ -782,14 +1992,39 @@ }, ); - // Layout toggle handler + // Layout toggle handler - toggles tile container size and layout jQuery(document).on('click', '#group-genmap-layout-toggle', function () { const wrapper = jQuery(TILE_SELECTOR); if (!wrapper.length) { return; } - const currentLayout = wrapper.data('currentLayout') || getDefaultLayout(); - const newLayout = currentLayout === 'l2r' ? 't2b' : 'l2r'; - switchLayout(wrapper, newLayout); + + // Check if D3 visualization is active + const useD3Visualization = wrapper.data('useD3Visualization'); + + if (useD3Visualization && typeof d3 !== 'undefined') { + // D3 visualization: Toggle tile container size and re-render with new height + const currentLayout = wrapper.data('currentLayout') || getDefaultLayout(); + const newLayout = currentLayout === 'l2r' ? 't2b' : 'l2r'; + + wrapper.data('currentLayout', newLayout); + saveLayoutPreference(newLayout); + updateLayoutToggleIcon(newLayout); + updateSectionWidth(newLayout); + + // Re-render D3 chart with new container height based on layout + const genmapData = wrapper.data('currentGenmapData'); + if (genmapData) { + // Small delay to ensure CSS changes are applied before re-rendering + setTimeout(() => { + renderD3Chart(wrapper, genmapData); + }, 100); + } + } else { + // Legacy orgchart: Toggle layout and tile container size + const currentLayout = wrapper.data('currentLayout') || getDefaultLayout(); + const newLayout = currentLayout === 'l2r' ? 't2b' : 'l2r'; + switchLayout(wrapper, newLayout); + } }); })(jQuery); diff --git a/dt-metrics/records/genmap.php b/dt-metrics/records/genmap.php index e2556d93df..249ce7bdd7 100644 --- a/dt-metrics/records/genmap.php +++ b/dt-metrics/records/genmap.php @@ -221,14 +221,23 @@ public function get_query( $post_type, $p2p_type, $p2p_direction, $filters = [] // Determine archived meta values. $status_key = $filters['status_key'] ?? ''; - // Prepare sql shape to be executed. - $prepared_query = $wpdb->prepare( " + // Add group_type field for groups post type + $group_type_select = ''; + $group_type_union_select = ''; + if ( $post_type === 'groups' ) { + $group_type_select = ", ( SELECT p_type.meta_value FROM $wpdb->postmeta as p_type WHERE ( p_type.post_id = a.ID ) AND ( p_type.meta_key = 'group_type' ) ) as group_type"; + $group_type_union_select = ", ( SELECT u_type.meta_value FROM $wpdb->postmeta as u_type WHERE ( u_type.post_id = p." . $select_id . " ) AND ( u_type.meta_key = 'group_type' ) ) as group_type"; + } + + // Build query string with optional group_type field + $query_sql = " SELECT a.ID as id, 0 as parent_id, a.post_title as name, ( SELECT p_status.meta_value FROM $wpdb->postmeta as p_status WHERE ( p_status.post_id = a.ID ) AND ( p_status.meta_key = %s ) ) as status, ( SELECT EXISTS( SELECT p_shared.user_id FROM $wpdb->dt_share as p_shared WHERE p_shared.user_id = %d AND p_shared.post_id = a.ID ) ) as shared + $group_type_select FROM $wpdb->posts as a WHERE a.post_type = %s AND a.ID %1s IN ( @@ -250,9 +259,12 @@ public function get_query( $post_type, $p2p_type, $p2p_direction, $filters = [] (SELECT sub.post_title FROM $wpdb->posts as sub WHERE sub.ID = p.%1s ) as name, ( SELECT u_status.meta_value FROM $wpdb->postmeta as u_status WHERE ( u_status.post_id = p.%1s ) AND ( u_status.meta_key = %s ) ) as status, ( SELECT EXISTS( SELECT u_shared.user_id FROM $wpdb->dt_share as u_shared WHERE u_shared.user_id = %d AND u_shared.post_id = p.%1s ) ) as shared + $group_type_union_select FROM $wpdb->p2p as p WHERE p.p2p_type = %s; - ", $status_key, $user->ID, $post_type, $not_from, $p2p_type, $not_to, $p2p_type, $select_id, $select_parent_id, $select_id, $select_id, $status_key, $user->ID, $select_id, $p2p_type ); + "; + + $prepared_query = $wpdb->prepare( $query_sql, $status_key, $user->ID, $post_type, $not_from, $p2p_type, $not_to, $p2p_type, $select_id, $select_parent_id, $select_id, $select_id, $status_key, $user->ID, $select_id, $p2p_type ); //phpcs:disable return $wpdb->get_results( $prepared_query, ARRAY_A ); @@ -311,7 +323,8 @@ public function build_array( $parent_id, $menu_data, $gen, $depth_limit, $filter 'name' => ( ( $shared === 1 ) || ( $gen === 0 ) ) ? ( $menu_data['items'][ $parent_id ]['name'] ?? 'SYSTEM' ) : '', 'status' => $menu_data['items'][ $parent_id ]['status'] ?? '', 'shared' => $shared, - 'content' => 'Gen ' . $gen + 'content' => 'Gen ' . $gen, + 'group_type' => $menu_data['items'][ $parent_id ]['group_type'] ?? '' ]; // Ensure to exclude non-shared generations. From 8bf891d816830882c58011619b58ff208208b89c Mon Sep 17 00:00:00 2001 From: kodinkat Date: Wed, 17 Dec 2025 16:19:21 +0000 Subject: [PATCH 5/6] Update SQL query in Genmap class to include safe handling for group_type field - Added a comment to ignore PHPCS warning for prepared SQL fragments related to group_type. - Ensured that the SQL query remains secure while incorporating the necessary group_type logic. --- dt-metrics/records/genmap.php | 1 + 1 file changed, 1 insertion(+) diff --git a/dt-metrics/records/genmap.php b/dt-metrics/records/genmap.php index 249ce7bdd7..f5c9a2dd79 100644 --- a/dt-metrics/records/genmap.php +++ b/dt-metrics/records/genmap.php @@ -230,6 +230,7 @@ public function get_query( $post_type, $p2p_type, $p2p_direction, $filters = [] } // Build query string with optional group_type field + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $group_type_select and $group_type_union_select are safe SQL fragments $query_sql = " SELECT a.ID as id, From 45804730be402cf38d211fb1a1c6dbdc2b6adc3b Mon Sep 17 00:00:00 2001 From: kodinkat Date: Wed, 17 Dec 2025 16:25:19 +0000 Subject: [PATCH 6/6] Refactor SQL query preparation in Genmap class - Removed redundant PHPCS disable comment and ensured consistent code style. - Maintained secure handling of SQL queries while preparing for group type logic. --- dt-metrics/records/genmap.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dt-metrics/records/genmap.php b/dt-metrics/records/genmap.php index f5c9a2dd79..7abed0dfe9 100644 --- a/dt-metrics/records/genmap.php +++ b/dt-metrics/records/genmap.php @@ -265,9 +265,9 @@ public function get_query( $post_type, $p2p_type, $p2p_direction, $filters = [] WHERE p.p2p_type = %s; "; + //phpcs:disable $prepared_query = $wpdb->prepare( $query_sql, $status_key, $user->ID, $post_type, $not_from, $p2p_type, $not_to, $p2p_type, $select_id, $select_parent_id, $select_id, $select_id, $status_key, $user->ID, $select_id, $p2p_type ); - //phpcs:disable return $wpdb->get_results( $prepared_query, ARRAY_A ); //phpcs:enable }