diff --git a/dt-groups/base-setup.php b/dt-groups/base-setup.php
index 86a2d3381..22f158864 100644
--- a/dt-groups/base-setup.php
+++ b/dt-groups/base-setup.php
@@ -27,6 +27,8 @@ 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_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 );
// hooks
@@ -462,6 +464,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 ) {
@@ -596,6 +599,84 @@ 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' ) ];
}
@@ -1125,6 +1207,108 @@ public function scripts(){
'jquery',
'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 );
+
+ $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 ) );
+ }
+
+ // 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', // Keep for now during migration
+ 'd3', // Add D3.js dependency
+ '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'];
+ }
+ }
+
+ // 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' ),
+ '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-d3.css b/dt-groups/genmap-d3.css
new file mode 100644
index 000000000..1f56322ee
--- /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
new file mode 100644
index 000000000..d37a8adc1
--- /dev/null
+++ b/dt-groups/genmap-tile.js
@@ -0,0 +1,2030 @@
+(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 LAYOUT_STORAGE_KEY = 'group_genmap_layout';
+ 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',
+ };
+
+ function getDefaultLayout() {
+ // Check if mobile (screen width < 768px)
+ const isMobile = window.innerWidth < 768;
+ if (isMobile) {
+ return 't2b'; // Vertical for mobile
+ }
+ // Check localStorage for saved preference
+ const saved = localStorage.getItem(LAYOUT_STORAGE_KEY);
+ if (saved === 't2b' || saved === 'l2r') {
+ return saved;
+ }
+ // Default to horizontal for desktop
+ return 'l2r';
+ }
+
+ function saveLayoutPreference(layout) {
+ localStorage.setItem(LAYOUT_STORAGE_KEY, layout);
+ }
+
+ function isMobileView() {
+ return window.innerWidth < 768;
+ }
+
+ jQuery(document).ready(() => {
+ // 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';
+ 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);
+ }
+ .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);
+ }
+
+ const wrapper = $(TILE_SELECTOR);
+ if (!wrapper.length) {
+ return;
+ }
+
+ // Initialize layout toggle button
+ const layoutToggle = $('#group-genmap-layout-toggle');
+ const currentLayout = getDefaultLayout();
+
+ // 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);
+ }
+
+ // 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;
+ }
+
+ // 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, layout = null) {
+ setMessage(wrapper, 'loading');
+ const currentLayout =
+ layout || wrapper.data('currentLayout') || getDefaultLayout();
+ wrapper.data('currentLayout', currentLayout);
+
+ 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;
+ }
+ const currentLayout =
+ wrapper.data('currentLayout') || getDefaultLayout();
+ const sanitizedGenmap = sanitizeNode(genmap);
+ // Store genmap data for layout switching
+ wrapper.data('currentGenmapData', sanitizedGenmap);
+
+ // 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');
+ })
+ .always(() => {
+ wrapper.addClass('group-genmap-loaded');
+ });
+ }
+
+ 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
+ .css({
+ overflow: 'auto',
+ width: '100%',
+ })
+ .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: currentLayout,
+ nodeTemplate: nodeTemplate,
+ 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 node selection.
+ container.off('click', '.node');
+ container.on('click', '.node', function () {
+ const node = $(this);
+ if (String(node.data('shared')) === '0') {
+ return;
+ }
+ const nodeId = node.attr('id');
+ const parentId = node.data('parent') || 0;
+ if (!nodeId) {
+ return;
+ }
+
+ // 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.
+ wrapper.data('orgchartInstance', orgchart);
+ 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) {
+ 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)
+ : [];
+
+ // 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 = '.......';
+ sanitized.content = '';
+ 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 || {};
+
+ // 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 = '';
+
+ // 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) {
+ 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] || '';
+ }
+
+ 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 (t2b), show icon to switch to horizontal
+ switchToVerticalIcon.hide();
+ switchToHorizontalIcon.show();
+ }
+ }
+
+ function openGenmapDetails(wrapper, nodeId, parentId, postType) {
+ const modal = $('#group-genmap-details-modal');
+ if (!modal.length) {
+ return;
+ }
+
+ const spinner = ' ';
+ $('#group-genmap-details-modal-content').html(spinner);
+ modal.foundation('open');
+
+ 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(parentId, postType, data);
+ } else {
+ $('#group-genmap-details-modal-content').html(
+ '' + getString('error') + '
',
+ );
+ }
+ })
+ .fail(() => {
+ $('#group-genmap-details-modal-content').html(
+ '' + getString('error') + '
',
+ );
+ });
+ }
+
+ 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');
+
+ // 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: ' +
+ 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) +
+ '
';
+ }
+
+ detailsHtml += '
';
+
+ // Build buttons line
+ const openUrl = `${urlBase}/${postTypeSlug}/${encodeURIComponent(data.ID)}`;
+ detailsHtml += '
';
+ detailsHtml += '';
+
+ modalContent.html(detailsHtml);
+ }
+
+ function displayAddChildModal(postType, postId, postName) {
+ // Close details modal if open
+ const detailsModal = jQuery('#group-genmap-details-modal');
+ if (detailsModal.length && detailsModal.hasClass('is-open')) {
+ detailsModal.foundation('close');
+ }
+
+ 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');
+ }
+ }
+
+ // 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'),
+ );
+ });
+
+ // Clear node selection when details modal closes
+ jQuery(document).on(
+ 'closed.zf.reveal',
+ '#group-genmap-details-modal',
+ function () {
+ const wrapper = jQuery(TILE_SELECTOR);
+ const container = wrapper.find('.group-genmap-chart');
+ container.find('.node').removeClass('group-genmap-node-selected');
+ },
+ );
+
+ 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();
+ },
+ );
+
+ // 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;
+ }
+
+ // 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 bbade5052..7abed0dfe 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 [
@@ -215,14 +221,24 @@ 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
+ // 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,
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 (
@@ -244,11 +260,14 @@ 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 );
+ ";
//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 );
+
return $wpdb->get_results( $prepared_query, ARRAY_A );
//phpcs:enable
}
@@ -305,7 +324,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.
diff --git a/single-template.php b/single-template.php
index 9a3f6e551..0d89faa3b 100644
--- a/single-template.php
+++ b/single-template.php
@@ -303,9 +303,13 @@ 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'] ) ) {