feat(org-chart): finalize chart features and AI generation rules
This commit is contained in:
1 parent
7614507583
commit
d0828a7ff4
80 files changed
+8152
-221
No files matched your search
@@ -0,0 +1,88 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyInitialVisibility, autoEngineOptions, resolveAutoLayout, resolveInteractions } from './auto-layout-policy.js';
|
||||
import { createOrgChart } from './index.js';
|
||||
import { engineTree, type RenderContext } from './render.js';
|
||||
import type { Organization, OrganizationTreeNode } from '../data/index.js';
|
||||
|
||||
const organization = (id: string, parentId: string | null): Organization => ({ id, parentId, name: id, type: parentId ? 'team' : 'root', leaderId: null, displayOrder: 0 });
|
||||
const organizations = [organization('root', null), organization('a', 'root'), organization('b', 'root'), organization('a1', 'a'), organization('a2', 'a1')];
|
||||
|
||||
test('each independent layout policy selects compatible installed engine options', () => {
|
||||
assert.deepEqual(autoEngineOptions(resolveAutoLayout()), { direction: 't2b', visibleLevel: 999 });
|
||||
assert.deepEqual(autoEngineOptions(resolveAutoLayout({ type: 'lr', initialVisibleDepth: 1 })), { direction: 'l2r', visibleLevel: 999 });
|
||||
for (const depth of [2, 3, 4] as const) assert.deepEqual(autoEngineOptions(resolveAutoLayout({ type: 'hybrid', hybridStartDepth: depth })), { direction: 't2b', visibleLevel: 999, verticalLevel: depth });
|
||||
});
|
||||
|
||||
test('collapse, Zoom and Pan have independent validated defaults', () => {
|
||||
assert.deepEqual(resolveInteractions(), { collapse: true, zoom: true, pan: true });
|
||||
assert.deepEqual(resolveInteractions({ collapse: false }), { collapse: false, zoom: true, pan: true });
|
||||
assert.deepEqual(resolveInteractions({ zoom: false }), { collapse: true, zoom: false, pan: true });
|
||||
assert.deepEqual(resolveInteractions({ pan: false }), { collapse: true, zoom: true, pan: false });
|
||||
assert.throws(() => resolveInteractions({ zoom: 0 as unknown as boolean }), /interactions.zoom must be boolean/);
|
||||
});
|
||||
|
||||
test('incompatible per-group/global/directional combinations fail explicitly', () => {
|
||||
assert.throws(() => resolveAutoLayout({ type: 'lr', hybridOrganizationIds: ['a'] }), /supported only with TB/);
|
||||
assert.throws(() => resolveAutoLayout({ type: 'hybrid', hybridOrganizationIds: ['a'] }), /supported only with TB/);
|
||||
assert.throws(() => resolveAutoLayout({ initialVisibleDepth: 0 as 1 }), /initialVisibleDepth/);
|
||||
assert.throws(() => resolveAutoLayout({ hybridStartDepth: 5 as 2 }), /hybridStartDepth/);
|
||||
assert.throws(() => resolveAutoLayout({ hybridOrganizationIds: [''] }), /nonempty/);
|
||||
const input = ['a', 'a'];
|
||||
const resolved = resolveAutoLayout({ hybridOrganizationIds: input });
|
||||
input.push('b');
|
||||
assert.deepEqual(resolved.hybridOrganizationIds, ['a']);
|
||||
assert.ok(Object.isFrozen(resolved.hybridOrganizationIds));
|
||||
});
|
||||
|
||||
test('initial depth is one-based and user expansion survives subsequent renders and new data', () => {
|
||||
const collapsed = new Set<string>(), initialized = new Set<string>();
|
||||
applyInitialVisibility(organizations, collapsed, initialized, 2);
|
||||
assert.deepEqual([...collapsed], ['a', 'b', 'a1', 'a2']);
|
||||
collapsed.delete('a');
|
||||
applyInitialVisibility([...organizations, organization('b1', 'b')], collapsed, initialized, 2);
|
||||
assert.equal(collapsed.has('a'), false);
|
||||
assert.equal(collapsed.has('b1'), true);
|
||||
applyInitialVisibility(organizations, collapsed, initialized, 'all', true);
|
||||
assert.equal(collapsed.size, 0);
|
||||
collapsed.add('a1');
|
||||
applyInitialVisibility(organizations.filter(item => item.id !== 'a2'), collapsed, initialized, 'all');
|
||||
assert.equal(collapsed.has('a1'), true);
|
||||
assert.equal(initialized.has('a2'), false);
|
||||
});
|
||||
|
||||
test('Depth 1 projection keeps independent company roots while collapsed descendants stay out of DOM', () => {
|
||||
const roots: OrganizationTreeNode[] = [{ organization: organizations[0]!, children: [{ organization: organizations[1]!, children: [{ organization: organizations[3]!, children: [] }] }] }];
|
||||
const collapsed = new Set<string>(), initialized = new Set<string>();
|
||||
applyInitialVisibility(organizations, collapsed, initialized, 1);
|
||||
const context = { orderedOrganizations: [], readOnly: true, collapsedOrganizations: collapsed, autoLayout: resolveAutoLayout() } as unknown as RenderContext;
|
||||
const tree = engineTree(roots, 'initial', context);
|
||||
assert.equal(tree.length, 1);
|
||||
assert.equal(tree[0]!.children, undefined);
|
||||
assert.deepEqual(context.orderedOrganizations.map(item => item.id), ['root']);
|
||||
});
|
||||
|
||||
test('per-group Hybrid projection marks only the selected parent and leaves source immutable', () => {
|
||||
const roots: OrganizationTreeNode[] = [{ organization: organizations[0]!, children: [{ organization: organizations[1]!, children: [{ organization: organizations[3]!, children: [] }] }, { organization: organizations[2]!, children: [] }] }];
|
||||
const before = structuredClone(roots);
|
||||
const context = { orderedOrganizations: [], readOnly: true, autoLayout: resolveAutoLayout({ hybridOrganizationIds: ['a'] }) } as unknown as RenderContext;
|
||||
const tree = engineTree(roots, 'groups', context);
|
||||
assert.equal(tree[0]!.hybrid, undefined);
|
||||
assert.equal(tree[0]!.level, 1);
|
||||
assert.equal(tree[0]!.children![0]!.hybrid, true);
|
||||
assert.equal(tree[0]!.children![0]!.level, 2);
|
||||
assert.equal(tree[0]!.children![0]!.children![0]!.level, 3);
|
||||
assert.equal(tree[0]!.children![1]!.hybrid, undefined);
|
||||
assert.deepEqual(roots, before);
|
||||
});
|
||||
|
||||
test('Fixed ignores Auto configuration and facade defaults keep existing TB fully expanded', () => {
|
||||
const options = { dataSource: { load: async () => ({}) }, stylesheetUrls: { engine: '/engine.css', core: '/style.css' } };
|
||||
const fixed = createOrgChart({ ...options, autoLayout: { type: 'invalid' as 'tb' } });
|
||||
assert.equal(fixed.getAutoLayout().type, 'tb');
|
||||
fixed.destroy();
|
||||
const expanded = createOrgChart({ ...options, layoutMode: 'auto' });
|
||||
assert.equal(expanded.getAutoLayout().initialVisibleDepth, 'all');
|
||||
assert.equal(expanded.getAutoLayout().type, 'tb');
|
||||
expanded.destroy();
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Organization } from '../data/index.js';
|
||||
import type { OrgChartAutoLayoutOptions, OrgChartAutoLayoutType, OrgChartInteractionOptions } from './types.js';
|
||||
|
||||
export function resolveInteractions(options: OrgChartInteractionOptions = {}): Readonly<Required<OrgChartInteractionOptions>> {
|
||||
for (const key of ['collapse', 'zoom', 'pan'] as const) {
|
||||
if (options[key] !== undefined && typeof options[key] !== 'boolean') throw new TypeError(`interactions.${key} must be boolean`);
|
||||
}
|
||||
return Object.freeze({ collapse: options.collapse ?? true, zoom: options.zoom ?? true, pan: options.pan ?? true });
|
||||
}
|
||||
|
||||
export interface ResolvedAutoLayout {
|
||||
readonly type: OrgChartAutoLayoutType;
|
||||
readonly hybridStartDepth: 2 | 3 | 4;
|
||||
readonly initialVisibleDepth: 1 | 2 | 3 | 'all';
|
||||
readonly hybridOrganizationIds: readonly string[];
|
||||
}
|
||||
|
||||
/** Match installed dabeng 6's mutually exclusive directional/vertical policies. */
|
||||
export function resolveAutoLayout(options: OrgChartAutoLayoutOptions = {}): ResolvedAutoLayout {
|
||||
const type = options.type ?? 'tb';
|
||||
const hybridStartDepth = options.hybridStartDepth ?? 3;
|
||||
const initialVisibleDepth = options.initialVisibleDepth ?? 'all';
|
||||
if (!['tb', 'lr', 'hybrid'].includes(type)) throw new TypeError('autoLayout.type must be tb, lr or hybrid');
|
||||
if (![2, 3, 4].includes(hybridStartDepth)) throw new TypeError('autoLayout.hybridStartDepth must be 2, 3 or 4');
|
||||
if (![1, 2, 3, 'all'].includes(initialVisibleDepth)) throw new TypeError('autoLayout.initialVisibleDepth must be 1, 2, 3 or all');
|
||||
if (options.hybridOrganizationIds !== undefined && (!Array.isArray(options.hybridOrganizationIds) || options.hybridOrganizationIds.some(id => typeof id !== 'string' || !id.trim()))) throw new TypeError('autoLayout.hybridOrganizationIds must contain nonempty organization IDs');
|
||||
const hybridOrganizationIds = Object.freeze([...new Set(options.hybridOrganizationIds ?? [])]);
|
||||
if (type !== 'tb' && hybridOrganizationIds.length) throw new TypeError('Per-group hybridOrganizationIds are supported only with TB; clear them before selecting LR or Hybrid');
|
||||
return Object.freeze({ type, hybridStartDepth, initialVisibleDepth, hybridOrganizationIds });
|
||||
}
|
||||
|
||||
export function autoEngineOptions(layout: ResolvedAutoLayout): { direction: 't2b' | 'l2r'; verticalLevel?: number; visibleLevel: number } {
|
||||
// Core owns collapse state and prunes its projection. Upstream visibleLevel
|
||||
// would retain hidden descendants that disagree with Core keyboard/state APIs.
|
||||
return { direction: layout.type === 'lr' ? 'l2r' : 't2b', visibleLevel: 999,
|
||||
...(layout.type === 'hybrid' ? { verticalLevel: layout.hybridStartDepth } : {}) };
|
||||
}
|
||||
|
||||
/** Apply the visibleLevel policy to new IDs only, keeping user changes stable. */
|
||||
export function applyInitialVisibility(organizations: readonly Organization[], collapsed: Set<string>, initialized: Set<string>, visibleDepth: ResolvedAutoLayout['initialVisibleDepth'], reset = false): void {
|
||||
const valid = new Set(organizations.map(organization => organization.id));
|
||||
for (const id of collapsed) if (!valid.has(id) || reset) collapsed.delete(id);
|
||||
for (const id of initialized) if (!valid.has(id) || reset) initialized.delete(id);
|
||||
const byId = new Map(organizations.map(organization => [organization.id, organization]));
|
||||
for (const organization of organizations) {
|
||||
if (initialized.has(organization.id)) continue;
|
||||
initialized.add(organization.id);
|
||||
if (visibleDepth === 'all') continue;
|
||||
let depth = 1;
|
||||
let current = organization;
|
||||
const seen = new Set([current.id]);
|
||||
while (current.parentId) {
|
||||
const parent = byId.get(current.parentId);
|
||||
if (!parent || seen.has(parent.id)) break;
|
||||
seen.add(parent.id); current = parent; depth++;
|
||||
}
|
||||
if (depth >= visibleDepth) collapsed.add(organization.id);
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,20 @@ export function createEmployeeInfoDialog(
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.type = 'button';
|
||||
closeButton.className = 'hm-orgchart-employee-dialog-close';
|
||||
closeButton.textContent = '닫기';
|
||||
closeButton.setAttribute('aria-label', '직원 정보 닫기');
|
||||
// HM Dialog's Lucide XIcon, kept as native SVG for the shared Vanilla Core.
|
||||
const closeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
for (const [name, value] of Object.entries({
|
||||
width: '24', height: '24', viewBox: '0 0 24 24', fill: 'none',
|
||||
stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round', 'aria-hidden': 'true', focusable: 'false',
|
||||
})) closeIcon.setAttribute(name, value);
|
||||
for (const d of ['M18 6 6 18', 'm6 6 12 12']) {
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', d);
|
||||
closeIcon.append(path);
|
||||
}
|
||||
closeButton.append(closeIcon);
|
||||
const fields = document.createElement('dl');
|
||||
fields.className = 'hm-orgchart-employee-dialog-fields';
|
||||
header.append(title, closeButton);
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import type OrgChart from 'orgchart';
|
||||
import type { EngineNode } from 'orgchart';
|
||||
import type { EngineNode, EngineOptions } from 'orgchart';
|
||||
|
||||
export type RenderEngineNode = EngineNode;
|
||||
export interface EngineEditing {
|
||||
canDrop(source: HTMLElement, target: HTMLElement): boolean;
|
||||
onDrop(source: HTMLElement, target: HTMLElement): void;
|
||||
}
|
||||
export interface EngineAdapter {
|
||||
update(data: RenderEngineNode[]): void;
|
||||
toggle(node: HTMLElement): boolean | undefined;
|
||||
prepareDrag(source: HTMLElement): void;
|
||||
drop(source: HTMLElement, target: HTMLElement): void;
|
||||
cancelDrag(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
@@ -51,6 +58,8 @@ export function createEngineAdapter(
|
||||
viewport: HTMLElement,
|
||||
data: RenderEngineNode[],
|
||||
decorate: (node: HTMLElement, data: RenderEngineNode, signal: AbortSignal) => void,
|
||||
layoutOptions: Pick<EngineOptions, 'direction' | 'verticalLevel' | 'visibleLevel'> = {},
|
||||
editing?: EngineEditing,
|
||||
): EngineAdapter {
|
||||
const Abort = viewport.ownerDocument.defaultView?.AbortController ?? AbortController;
|
||||
let generation = new Abort();
|
||||
@@ -59,7 +68,12 @@ export function createEngineAdapter(
|
||||
const forest = viewport.ownerDocument.createElement('div');
|
||||
forest.className = 'hm-orgchart-forest';
|
||||
viewport.append(forest);
|
||||
function cancelDrag(): void {
|
||||
if (!editing) return;
|
||||
for (const instance of instances) instance.dragendHandler();
|
||||
}
|
||||
function releaseGeneration(): void {
|
||||
cancelDrag();
|
||||
generation.abort();
|
||||
for (const instance of instances) {
|
||||
instance.disconnectInitObserver();
|
||||
@@ -84,8 +98,10 @@ export function createEngineAdapter(
|
||||
treeContainer.className = 'hm-orgchart-tree';
|
||||
forest.append(treeContainer);
|
||||
const instance = new Engine(treeContainer, {
|
||||
...layoutOptions,
|
||||
data: root, pan: false, zoom: false, minimap: false, draggable: false, exportButton: false,
|
||||
chartClass: 'hm-orgchart-engine',
|
||||
...(editing ? { dropCriteria: (source: HTMLElement, _parent: HTMLElement | null, target: HTMLElement) => editing.canDrop(source, target) } : {}),
|
||||
nodeTemplate: () => '<article class="hm-orgchart-organization"></article>',
|
||||
createNode: (node, value) => decorate(node, value, generation.signal),
|
||||
});
|
||||
@@ -108,6 +124,23 @@ export function createEngineAdapter(
|
||||
}
|
||||
function bindGeneration(): void {
|
||||
forest.addEventListener('transitionend', syncButtons, { signal: generation.signal });
|
||||
if (!editing) return;
|
||||
for (const instance of instances) {
|
||||
// Core owns immutable data and rerendering. Reuse upstream drop events,
|
||||
// but cancel its DOM/data mutation before either horizontal or hybrid path.
|
||||
const commitDrop = (event: Event) => {
|
||||
event.preventDefault();
|
||||
const detail = (event as CustomEvent<{ draggedNode?: HTMLElement; draggedItem?: HTMLElement; dropZone: HTMLElement }>).detail;
|
||||
if (!detail) return;
|
||||
const source = detail.draggedNode ?? detail.draggedItem;
|
||||
const target = detail.dropZone;
|
||||
if (source && target && instance.chart?.contains(source) && instances.some(candidate => candidate.chart?.contains(target)) && editing.canDrop(source, target)) {
|
||||
editing.onDrop(source, target);
|
||||
}
|
||||
};
|
||||
instance.chart?.addEventListener('nodedrop.orgchart', commitDrop, { signal: generation.signal });
|
||||
instance.chart?.addEventListener('otherdropped.orgchart', commitDrop, { signal: generation.signal });
|
||||
}
|
||||
}
|
||||
build(data);
|
||||
bindGeneration();
|
||||
@@ -133,6 +166,25 @@ export function createEngineAdapter(
|
||||
syncButtons();
|
||||
return !expanded;
|
||||
},
|
||||
prepareDrag(source) {
|
||||
if (disposed || !editing) return;
|
||||
cancelDrag();
|
||||
if (!instances.some(candidate => candidate.chart?.contains(source))) return;
|
||||
// Independent company trees share one editing policy. Upstream caches the
|
||||
// source and filters targets within its own chart, so filter each tree.
|
||||
for (const instance of instances) instance.filterAllowedDropNodes(source);
|
||||
},
|
||||
drop(source, target) {
|
||||
if (disposed || !editing) return;
|
||||
const instance = instances.find(candidate => candidate.chart?.contains(source));
|
||||
if (!instance || !instances.some(candidate => candidate.chart?.contains(target)) || !editing.canDrop(source, target)) { cancelDrag(); return; }
|
||||
// Refresh upstream's cached source and allowed targets for the final data
|
||||
// policy check. Pointer coordinates and thresholds stay in the Core UI.
|
||||
for (const candidate of instances) candidate.filterAllowedDropNodes(source);
|
||||
void instance.dropHandler({ dropZone: target });
|
||||
cancelDrag();
|
||||
},
|
||||
cancelDrag,
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
|
||||
@@ -19,6 +19,13 @@ export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode,
|
||||
node.dataset.organizationType = organization.type;
|
||||
node.dataset.companyId = companyId;
|
||||
node.dataset.collapsed = String(collapsed);
|
||||
node.draggable = false;
|
||||
const counts = context.summary.raw.byOrganization[organization.id];
|
||||
node.dataset.directEmployeeCount = String(counts?.direct ?? 0);
|
||||
node.dataset.inclusiveEmployeeCount = String(counts?.inclusive ?? 0);
|
||||
const vertical = value.vertical === true || (context.autoLayout?.type === 'hybrid' && Number(value.depth) + 1 >= context.autoLayout.hybridStartDepth);
|
||||
node.dataset.verticalLayout = String(vertical);
|
||||
const layoutDirection = context.autoLayout?.type === 'lr' || vertical ? 'left' : 'top';
|
||||
node.style.setProperty('--hm-orgchart-expanded-primary', preset.primary);
|
||||
node.style.setProperty('--hm-orgchart-expanded-title-ink', ['saman', 'jangheon-industry'].includes(companyId) ? 'var(--hm-orgchart-resolved-fg-primary)' : 'var(--hm-orgchart-resolved-on-action-primary)');
|
||||
preset.secondary.forEach((color, index) => node.style.setProperty(`--hm-orgchart-expanded-secondary-${index + 1}`, color));
|
||||
@@ -34,10 +41,11 @@ export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode,
|
||||
button.className = 'hm-orgchart-expanded-toggle';
|
||||
button.dataset.organizationId = organization.id;
|
||||
button.textContent = `${top?.organizationName ?? organization.name} ${hasChildren ? collapsed ? '▸' : '▾' : '·'}`;
|
||||
button.setAttribute('aria-label', `${top?.organizationName ?? organization.name} 하위 조직·직원`);
|
||||
button.setAttribute('aria-label', `${top?.organizationName ?? organization.name} 하위 조직·직원 · 직접 ${counts?.direct ?? 0}명 · 하위 포함 ${counts?.inclusive ?? 0}명`);
|
||||
button.setAttribute('aria-expanded', String(!collapsed));
|
||||
button.disabled = !hasChildren;
|
||||
button.addEventListener('click', event => { event.stopPropagation(); context.toggleExpanded?.(organization.id); }, { signal });
|
||||
// Empty titles remain focusable move sources/targets. Collapse is independent.
|
||||
button.setAttribute('aria-disabled', String(!hasChildren || context.interactions?.collapse === false));
|
||||
button.addEventListener('click', event => { event.stopPropagation(); if (hasChildren && context.interactions?.collapse !== false) context.toggleExpanded?.(organization.id); }, { signal });
|
||||
heading.append(button);
|
||||
const href = context.organizationLinks?.[organization.id];
|
||||
if (href) {
|
||||
@@ -51,6 +59,7 @@ export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode,
|
||||
}
|
||||
article.append(heading);
|
||||
const colorCard = (card: HTMLElement, affiliationId: string) => {
|
||||
card.draggable = false;
|
||||
const employeePreset = getCompanyColorPreset(affiliationId) ?? preset;
|
||||
card.dataset.companyId = employeePreset.companyId;
|
||||
card.style.setProperty('--hm-orgchart-expanded-card-primary', `var(--hm-orgchart-company-${employeePreset.companyId})`);
|
||||
@@ -59,7 +68,7 @@ export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode,
|
||||
if (leader && top?.display !== 'hidden' && top?.display !== 'titleOnly') {
|
||||
const presentation = top ? { ...leader, position: top.title ?? leader.position, name: top.name ?? leader.name, profileImage: top.profileImage === undefined ? leader.profileImage : top.profileImage } : leader;
|
||||
const card = colorCard(createEmployeeCard(document, presentation, { kind: organization.parentId === null ? 'topLeader' : 'leader', expandedPresentation: true, hideStatusLabel: true }, signal), leader.affiliationId);
|
||||
card.dataset.connectionDirection = 'top';
|
||||
card.dataset.connectionDirection = context.autoLayout?.type === 'lr' ? 'left' : 'top';
|
||||
article.append(card);
|
||||
} else if (leader && top?.display === 'titleOnly') {
|
||||
const title = document.createElement('p');
|
||||
@@ -70,7 +79,7 @@ export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode,
|
||||
if (!collapsed && staff.length) {
|
||||
const employees = document.createElement('div');
|
||||
employees.className = 'hm-orgchart-employees';
|
||||
const direction = context.employeeConnections?.[organization.id] ?? 'top';
|
||||
const direction = context.employeeConnections?.[organization.id] ?? layoutDirection;
|
||||
employees.dataset.connectionDirection = direction;
|
||||
for (const employee of staff) {
|
||||
const card = colorCard(createEmployeeCard(document, employee, { kind: 'employee', expandedPresentation: true, hideStatusLabel: true }, signal), employee.affiliationId);
|
||||
@@ -86,6 +95,10 @@ export function createExpandedOrgChartContent(document: Document, context: Rende
|
||||
content.className = 'hm-orgchart-expanded';
|
||||
content.dataset.component = 'ExpandedOrgChartContent';
|
||||
content.dataset.layoutMode = 'auto';
|
||||
content.dataset.autoLayout = context.autoLayout?.type ?? 'tb';
|
||||
content.dataset.hybridStartDepth = String(context.autoLayout?.hybridStartDepth ?? 3);
|
||||
content.dataset.initialVisibleDepth = String(context.autoLayout?.initialVisibleDepth ?? 'all');
|
||||
content.dataset.groupHybrid = String(!!context.autoLayout?.hybridOrganizationIds.length);
|
||||
content.dataset.layoutStatus = 'hierarchical';
|
||||
content.dataset.cardDesignStatus = 'current-presentation';
|
||||
for (const company of new Set([...context.organizationCompanies!.values(), ...context.data.employees.map(employee => employee.affiliationId)])) {
|
||||
@@ -94,6 +107,6 @@ export function createExpandedOrgChartContent(document: Document, context: Rende
|
||||
}
|
||||
tree.setAttribute('aria-label', '확장형 조직 계층도 · Ctrl 또는 Command + 휠로 확대 · 드래그로 이동');
|
||||
content.append(createHeading(document, context), tree);
|
||||
alignExpandedLevels(tree, signal, layoutReady);
|
||||
alignExpandedLevels(tree, signal, layoutReady, context.autoLayout);
|
||||
return content;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { OrgChartMoveRequest, OrgChartMoveValidation } from '../data/index.js';
|
||||
import type { EngineAdapter } from './engine.js';
|
||||
|
||||
export interface ExpandedEditing {
|
||||
setEnabled(enabled: boolean): void;
|
||||
}
|
||||
|
||||
/** The pointer threshold is shared conceptually with Pan (6 screen pixels).
|
||||
* Upstream owns allowedDrop and drop events; Core owns immutable relationships.
|
||||
* Browser hit testing already includes the forest's Zoom/Pan transform. */
|
||||
export function createExpandedEditing(viewport: HTMLElement, feedback: HTMLElement, signal: AbortSignal,
|
||||
engine: EngineAdapter, validate: (request: OrgChartMoveRequest) => OrgChartMoveValidation,
|
||||
move: (request: OrgChartMoveRequest) => void, suppressClick: () => void, initiallyEnabled = false): ExpandedEditing {
|
||||
const document = viewport.ownerDocument, view = document.defaultView!;
|
||||
let enabled = initiallyEnabled;
|
||||
const nodes = () => [...viewport.querySelectorAll<HTMLElement>('.node[data-organization-id]')];
|
||||
let pending: { source: HTMLElement; id: number; x: number; y: number; moved: boolean } | undefined;
|
||||
let keyboardSource: HTMLElement | undefined;
|
||||
let hover: HTMLElement | undefined;
|
||||
const sourceFor = (target: EventTarget | null): HTMLElement | undefined => {
|
||||
if (!(target instanceof view.Element)) return;
|
||||
const employee = target.closest<HTMLElement>('.hm-orgchart-employee');
|
||||
if (employee && viewport.contains(employee)) return employee;
|
||||
const title = target.closest('.hm-orgchart-expanded-toggle');
|
||||
if (title && viewport.contains(title)) return title.closest<HTMLElement>('.node') ?? undefined;
|
||||
};
|
||||
const requestFor = (source: HTMLElement, target?: HTMLElement): OrgChartMoveRequest => ({
|
||||
kind: source.matches('.hm-orgchart-employee') ? 'employee' : 'organization',
|
||||
sourceId: source.dataset.employeeId ?? source.dataset.organizationId!,
|
||||
targetOrganizationId: target?.dataset.organizationId ?? '',
|
||||
});
|
||||
const name = (node: HTMLElement) => node.querySelector('.hm-orgchart-expanded-toggle')?.textContent ?? node.dataset.organizationId!;
|
||||
function clear(): void {
|
||||
engine.cancelDrag();
|
||||
for (const element of viewport.querySelectorAll<HTMLElement>('[data-drop-state], [data-drop-hover], [data-edit-source]')) {
|
||||
delete element.dataset.dropState; delete element.dataset.dropHover; delete element.dataset.editSource;
|
||||
}
|
||||
hover = undefined; delete viewport.dataset.editDragging;
|
||||
}
|
||||
function start(source: HTMLElement): void {
|
||||
clear(); engine.prepareDrag(source);
|
||||
source.dataset.editSource = 'true'; viewport.dataset.editDragging = 'true';
|
||||
for (const target of nodes()) target.dataset.dropState = validate(requestFor(source, target)).allowed ? 'allowed' : 'blocked';
|
||||
feedback.dataset.moveStatus = 'dragging';
|
||||
feedback.textContent = '이동할 조직에 놓으세요. 회사 간 이동도 가능합니다. 조직장·최상위 조직은 이동할 수 없습니다.';
|
||||
}
|
||||
function selectTarget(source: HTMLElement, target?: HTMLElement): void {
|
||||
if (hover) delete hover.dataset.dropHover;
|
||||
hover = target;
|
||||
if (!target) return;
|
||||
const policy = validate(requestFor(source, target));
|
||||
target.dataset.dropHover = policy.allowed ? 'allowed' : 'blocked';
|
||||
feedback.textContent = policy.allowed ? `${name(target)} · 이동 가능` : `${name(target)} · ${policy.message}`;
|
||||
}
|
||||
const hitTarget = (x: number, y: number) => {
|
||||
const box = viewport.getBoundingClientRect();
|
||||
if (x < box.left || x >= box.right || y < box.top || y >= box.bottom) return;
|
||||
// The presenter binds before its fragment is attached to the host Shadow DOM.
|
||||
// Resolve the live root at hit-test time instead of retaining that fragment.
|
||||
const root = viewport.getRootNode();
|
||||
const hit = (root instanceof view.ShadowRoot ? root : document).elementFromPoint(x, y);
|
||||
const node = hit?.closest<HTMLElement>('.node[data-organization-id]');
|
||||
return node && viewport.contains(node) ? node : undefined;
|
||||
};
|
||||
function drop(source: HTMLElement, target?: HTMLElement): boolean {
|
||||
if (!enabled) { clear(); return false; }
|
||||
const request = requestFor(source, target), policy = validate(request);
|
||||
// The upstream otherdropped event intentionally skips its allowedDrop check.
|
||||
// Always revalidate before entering either upstream drop-event route.
|
||||
if (target && policy.allowed) engine.drop(source, target);
|
||||
else move(request);
|
||||
clear();
|
||||
return policy.allowed && !!target;
|
||||
}
|
||||
viewport.addEventListener('dragstart', event => event.preventDefault(), { capture: true, signal });
|
||||
viewport.addEventListener('pointerdown', event => {
|
||||
if (!enabled || event.button !== 0 || !event.isPrimary || keyboardSource) return;
|
||||
const source = sourceFor(event.target);
|
||||
if (!source) return;
|
||||
pending = { source, id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
|
||||
}, { signal });
|
||||
view.addEventListener('pointermove', event => {
|
||||
if (!enabled || !pending || pending.id !== event.pointerId) return;
|
||||
if (!pending.moved && Math.hypot(event.clientX - pending.x, event.clientY - pending.y) < 6) return;
|
||||
if (!pending.moved) {
|
||||
pending.moved = true; viewport.setPointerCapture(event.pointerId); start(pending.source);
|
||||
}
|
||||
event.preventDefault();
|
||||
selectTarget(pending.source, hitTarget(event.clientX, event.clientY));
|
||||
}, { passive: false, signal });
|
||||
function finish(event: PointerEvent): void {
|
||||
if (!pending || pending.id !== event.pointerId) return;
|
||||
const drag = pending; pending = undefined;
|
||||
if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId);
|
||||
if (!drag.moved) return;
|
||||
suppressClick();
|
||||
if (event.type === 'pointerup') drop(drag.source, hitTarget(event.clientX, event.clientY));
|
||||
else { clear(); feedback.dataset.moveStatus = 'cancelled'; feedback.textContent = '이동을 취소했습니다.'; }
|
||||
}
|
||||
view.addEventListener('pointerup', finish, { signal });
|
||||
view.addEventListener('pointercancel', finish, { signal });
|
||||
viewport.addEventListener('lostpointercapture', event => {
|
||||
// Touch initially captures its card. Transferring capture to the viewport
|
||||
// releases that descendant and bubbles here; the viewport still owns Drag.
|
||||
if (event.target !== viewport || !pending?.moved) return;
|
||||
pending = undefined; clear(); feedback.textContent = '이동을 취소했습니다.';
|
||||
}, { signal });
|
||||
const cancel = (restoreFocus = true) => {
|
||||
const source = keyboardSource;
|
||||
if (pending?.moved) suppressClick();
|
||||
const id = pending?.id; pending = undefined; keyboardSource = undefined;
|
||||
if (id !== undefined && viewport.hasPointerCapture(id)) viewport.releasePointerCapture(id);
|
||||
clear(); feedback.dataset.moveStatus = 'cancelled'; feedback.textContent = '이동을 취소했습니다.';
|
||||
const focus = source?.matches('.node') ? source.querySelector<HTMLElement>('.hm-orgchart-expanded-toggle') : source;
|
||||
if (restoreFocus) focus?.focus({ preventScroll: true });
|
||||
};
|
||||
view.addEventListener('blur', () => { if (pending || keyboardSource) cancel(); }, { signal });
|
||||
viewport.addEventListener('keydown', event => {
|
||||
if (!enabled) return;
|
||||
if (event.key === 'Escape' && (keyboardSource || pending)) { event.preventDefault(); event.stopImmediatePropagation(); cancel(); return; }
|
||||
if (!keyboardSource && event.altKey && event.key.toLowerCase() === 'm') {
|
||||
const source = sourceFor(event.target);
|
||||
if (!source) return;
|
||||
event.preventDefault(); event.stopImmediatePropagation(); keyboardSource = source; start(source);
|
||||
const targets = nodes(), target = targets.find(target => validate(requestFor(source, target)).allowed) ?? targets[0];
|
||||
selectTarget(source, target);
|
||||
if (target) { target.tabIndex = -1; target.focus({ preventScroll: true }); }
|
||||
return;
|
||||
}
|
||||
if (!keyboardSource) return;
|
||||
if (['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft', 'Home', 'End'].includes(event.key)) {
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
const targets = nodes(), index = Math.max(0, targets.indexOf(hover!));
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? targets.length - 1
|
||||
: (index + (['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1) + targets.length) % targets.length;
|
||||
selectTarget(keyboardSource, targets[next]);
|
||||
if (hover) { hover.tabIndex = -1; hover.focus({ preventScroll: true }); }
|
||||
} else if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault(); event.stopImmediatePropagation(); const source = keyboardSource; keyboardSource = undefined;
|
||||
if (!drop(source, hover)) {
|
||||
const focus = source.matches('.node') ? source.querySelector<HTMLElement>('.hm-orgchart-expanded-toggle') : source;
|
||||
focus?.focus({ preventScroll: true });
|
||||
}
|
||||
} else if (event.key === 'Tab') { event.preventDefault(); event.stopImmediatePropagation(); cancel(); }
|
||||
}, { capture: true, signal });
|
||||
signal.addEventListener('abort', () => {
|
||||
const id = pending?.id; pending = undefined; keyboardSource = undefined;
|
||||
if (id !== undefined && viewport.hasPointerCapture(id)) viewport.releasePointerCapture(id);
|
||||
clear();
|
||||
}, { once: true });
|
||||
return {
|
||||
setEnabled(next) {
|
||||
if (enabled === next) return;
|
||||
enabled = next;
|
||||
if (!enabled) {
|
||||
if (pending || keyboardSource) cancel(false);
|
||||
else clear();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
/** dabeng owns recursive widths and connectors. Reserve the tallest content
|
||||
* height per depth so child rows align with unequal staff counts. Observe the
|
||||
* articles rather than reserved nodes to avoid ResizeObserver feedback. */
|
||||
export function alignExpandedLevels(viewport: HTMLElement, signal: AbortSignal, layoutReady: () => void = () => {}): void {
|
||||
import type { ResolvedAutoLayout } from './auto-layout-policy.js';
|
||||
|
||||
export function alignExpandedLevels(viewport: HTMLElement, signal: AbortSignal, layoutReady: () => void = () => {}, layout?: ResolvedAutoLayout): void {
|
||||
const view = viewport.ownerDocument.defaultView;
|
||||
if (!view || signal.aborted) return;
|
||||
const nodes = [...viewport.querySelectorAll<HTMLElement>('.node[data-depth]')];
|
||||
@@ -10,15 +12,20 @@ export function alignExpandedLevels(viewport: HTMLElement, signal: AbortSignal,
|
||||
frame = 0;
|
||||
if (signal.aborted) return;
|
||||
const heights = new Map<string, number>();
|
||||
const widths = new Map<string, number>();
|
||||
for (const node of nodes) {
|
||||
const height = node.querySelector<HTMLElement>('.hm-orgchart-organization')?.offsetHeight ?? 0;
|
||||
heights.set(node.dataset.depth!, Math.max(heights.get(node.dataset.depth!) ?? 0, Math.ceil(height)));
|
||||
const width = node.querySelector<HTMLElement>('.hm-orgchart-organization')?.offsetWidth ?? 0;
|
||||
widths.set(node.dataset.depth!, Math.max(widths.get(node.dataset.depth!) ?? 0, Math.ceil(width)));
|
||||
}
|
||||
for (const node of nodes) {
|
||||
node.style.minHeight = `${heights.get(node.dataset.depth!) ?? 0}px`;
|
||||
node.style.minHeight = layout?.type === 'lr' || node.closest('.nodes.vertical') ? '0' : `${heights.get(node.dataset.depth!) ?? 0}px`;
|
||||
if (layout?.type === 'lr') node.style.minWidth = `${widths.get(node.dataset.depth!) ?? 0}px`;
|
||||
node.style.setProperty('--hm-orgchart-expanded-content-height', `${node.querySelector<HTMLElement>('.hm-orgchart-organization')?.offsetHeight ?? 0}px`);
|
||||
}
|
||||
drawEmployeeConnectors(viewport);
|
||||
if (layout?.type === 'lr' || layout?.type === 'hybrid' || layout?.hybridOrganizationIds.length) drawOrganizationConnectors(viewport, layout);
|
||||
layoutReady();
|
||||
};
|
||||
const schedule = () => {
|
||||
@@ -36,6 +43,58 @@ export function alignExpandedLevels(viewport: HTMLElement, signal: AbortSignal,
|
||||
schedule();
|
||||
}
|
||||
|
||||
/** Adapt dabeng's existing hierarchy DOM to tall HM cards. These paths own only
|
||||
* connector presentation; dabeng still creates and recursively lays out nodes. */
|
||||
function drawOrganizationConnectors(viewport: HTMLElement, layout: ResolvedAutoLayout): void {
|
||||
const document = viewport.ownerDocument;
|
||||
for (const tree of viewport.querySelectorAll<HTMLElement>('.hm-orgchart-tree')) {
|
||||
tree.querySelector(':scope > .hm-orgchart-organization-connectors')?.remove();
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.classList.add('hm-orgchart-organization-connectors');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
svg.setAttribute('width', String(tree.offsetWidth));
|
||||
svg.setAttribute('height', String(tree.offsetHeight));
|
||||
const nodes = [...tree.querySelectorAll<HTMLElement>('.node[data-organization-id]')];
|
||||
const byId = new Map(nodes.map(node => [node.dataset.organizationId!, node]));
|
||||
const local = (element: HTMLElement) => {
|
||||
const treeRect = tree.getBoundingClientRect(), rect = element.getBoundingClientRect();
|
||||
// Forest scale is the only transform (LR's upstream rotation is scoped out).
|
||||
const scale = Number(viewport.dataset.scale) || 1;
|
||||
return { x: (rect.left - treeRect.left) / scale, y: (rect.top - treeRect.top) / scale,
|
||||
width: element.offsetWidth, height: element.offsetHeight };
|
||||
};
|
||||
for (const node of nodes) {
|
||||
const parent = byId.get(node.dataset.parentId!);
|
||||
if (!parent) continue;
|
||||
const article = parent.querySelector<HTMLElement>(':scope > .hm-orgchart-organization')!;
|
||||
const heading = node.querySelector<HTMLElement>('.hm-orgchart-organization-name')!;
|
||||
const from = local(article), to = local(heading);
|
||||
const left = layout.type === 'lr' || !!node.closest('.nodes.vertical');
|
||||
const path = document.createElementNS(svg.namespaceURI, 'path');
|
||||
if (layout.type === 'lr') {
|
||||
const parentHeading = local(parent.querySelector<HTMLElement>('.hm-orgchart-organization-name')!);
|
||||
const x = parentHeading.x + parentHeading.width, y = parentHeading.y + parentHeading.height / 2;
|
||||
const targetY = to.y + to.height / 2, rail = (x + to.x) / 2;
|
||||
path.setAttribute('d', `M ${x} ${y} H ${rail} V ${targetY} H ${to.x}`);
|
||||
} else if (left) {
|
||||
const x = from.x + from.width / 2, y = from.y + from.height;
|
||||
const rail = to.x - 16, targetY = to.y + to.height / 2;
|
||||
const branchY = y + 12;
|
||||
path.setAttribute('d', `M ${x} ${y} V ${branchY} H ${rail} V ${targetY} H ${to.x}`);
|
||||
} else {
|
||||
const x = from.x + from.width / 2, y = from.y + from.height;
|
||||
const targetX = to.x + to.width / 2, rail = (y + to.y) / 2;
|
||||
path.setAttribute('d', `M ${x} ${y} V ${rail} H ${targetX} V ${to.y}`);
|
||||
}
|
||||
path.setAttribute('data-organization-id', node.dataset.organizationId!);
|
||||
path.setAttribute('data-parent-id', node.dataset.parentId!);
|
||||
path.setAttribute('data-direction', left ? 'left' : 'top');
|
||||
svg.append(path);
|
||||
}
|
||||
tree.prepend(svg);
|
||||
}
|
||||
}
|
||||
|
||||
/** Draw routes in unscaled article coordinates. The endpoint defines both the
|
||||
* connector's incoming direction and the single accent side on the card. */
|
||||
function drawEmployeeConnectors(viewport: HTMLElement): void {
|
||||
|
||||
@@ -5,20 +5,25 @@ export interface ExpandedViewport {
|
||||
panBy(x: number, y: number): void;
|
||||
fit(): void;
|
||||
state(): OrgChartViewportState;
|
||||
hasManualPosition(): boolean;
|
||||
layoutReady(): void;
|
||||
}
|
||||
|
||||
/** One transformed forest, shared by HTML and React; dialogs live in the shell. */
|
||||
export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLElement, signal: AbortSignal,
|
||||
config: { min: number; max: number; step: number }, initial?: OrgChartViewportState, fitInitially = true): ExpandedViewport {
|
||||
config: { min: number; max: number; step: number; zoomEnabled?: boolean; panEnabled?: boolean }, initial?: OrgChartViewportState, centerInitially = true, initialManualPosition = false): ExpandedViewport {
|
||||
const document = viewport.ownerDocument;
|
||||
const view = document.defaultView!;
|
||||
const forest = viewport.querySelector<HTMLElement>('.hm-orgchart-forest')!;
|
||||
let { scale, x, y } = initial ?? { scale: 1, x: 0, y: 0 };
|
||||
let initialFit = fitInitially;
|
||||
let manualPosition = initialManualPosition;
|
||||
let placement: 'center' | 'fit' | undefined = centerInitially && !manualPosition ? 'center' : undefined;
|
||||
let drag: { id: number; x: number; y: number; moved: boolean } | undefined;
|
||||
let suppressClick = false;
|
||||
let focusFrame = 0;
|
||||
const zoomEnabled = config.zoomEnabled !== false, panEnabled = config.panEnabled !== false;
|
||||
viewport.dataset.zoomEnabled = String(zoomEnabled);
|
||||
viewport.dataset.panEnabled = String(panEnabled);
|
||||
const output = document.createElement('output');
|
||||
output.setAttribute('aria-label', '현재 배율');
|
||||
output.setAttribute('aria-live', 'polite');
|
||||
@@ -29,35 +34,61 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
|
||||
viewport.dataset.panX = String(x);
|
||||
viewport.dataset.panY = String(y);
|
||||
output.textContent = `${Math.round(scale * 100)}%`;
|
||||
buttons[0].disabled = scale >= config.max;
|
||||
buttons[1].disabled = scale <= config.min;
|
||||
buttons[0].disabled = !zoomEnabled || scale >= config.max;
|
||||
buttons[1].disabled = !zoomEnabled || scale <= config.min;
|
||||
};
|
||||
const clamp = (value: number) => Math.max(config.min, Math.min(config.max, value));
|
||||
// Engine wrappers reserve asymmetric margins (LR) and empty row space (TB).
|
||||
// Center the complete rendered content, including organization connector rails,
|
||||
// in forest-local coordinates so Pan/Zoom do not affect the measured bounds.
|
||||
const contentBounds = () => {
|
||||
const origin = forest.getBoundingClientRect();
|
||||
let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
|
||||
for (const element of forest.querySelectorAll('.hm-orgchart-organization, .hm-orgchart-organization-connectors path')) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if ((!rect.width && !rect.height) || !element.getClientRects().length) continue;
|
||||
left = Math.min(left, (rect.left - origin.left) / scale);
|
||||
top = Math.min(top, (rect.top - origin.top) / scale);
|
||||
right = Math.max(right, (rect.right - origin.left) / scale);
|
||||
bottom = Math.max(bottom, (rect.bottom - origin.top) / scale);
|
||||
}
|
||||
return Number.isFinite(left) ? { left, top, width: right - left, height: bottom - top } : undefined;
|
||||
};
|
||||
const position = (fit: boolean) => {
|
||||
const bounds = contentBounds();
|
||||
if (!bounds || !bounds.width || !bounds.height || !viewport.clientWidth || !viewport.clientHeight) return;
|
||||
if (fit) {
|
||||
// Only an explicit Fit shrinks to contain, even below manual Zoom's min.
|
||||
scale = Math.max(Number.EPSILON, Math.min((viewport.clientWidth - 32) / bounds.width, (viewport.clientHeight - 32) / bounds.height, 1));
|
||||
}
|
||||
x = (viewport.clientWidth - bounds.width * scale) / 2 - bounds.left * scale;
|
||||
y = (fit ? Math.max(16, (viewport.clientHeight - bounds.height * scale) / 2) : 16) - bounds.top * scale;
|
||||
apply();
|
||||
};
|
||||
const preservePosition = () => { manualPosition = true; placement = undefined; };
|
||||
const controller: ExpandedViewport = {
|
||||
zoomTo(next, anchor = { x: viewport.clientWidth / 2, y: viewport.clientHeight / 2 }) {
|
||||
if (!zoomEnabled) return;
|
||||
if (!Number.isFinite(next)) return;
|
||||
initialFit = false;
|
||||
preservePosition();
|
||||
const value = clamp(next), factor = value / scale;
|
||||
x = anchor.x - (anchor.x - x) * factor;
|
||||
y = anchor.y - (anchor.y - y) * factor;
|
||||
scale = value; apply();
|
||||
},
|
||||
panBy(dx, dy) { if (!Number.isFinite(dx) || !Number.isFinite(dy)) return; initialFit = false; x += dx; y += dy; apply(); },
|
||||
panBy(dx, dy) { if (!panEnabled || !Number.isFinite(dx) || !Number.isFinite(dy)) return; preservePosition(); x += dx; y += dy; apply(); },
|
||||
fit() {
|
||||
if (!forest.offsetWidth || !forest.offsetHeight) return;
|
||||
scale = clamp(Math.min((viewport.clientWidth - 32) / forest.offsetWidth, (viewport.clientHeight - 32) / forest.offsetHeight, 1));
|
||||
x = (viewport.clientWidth - forest.offsetWidth * scale) / 2;
|
||||
y = Math.max(16, (viewport.clientHeight - forest.offsetHeight * scale) / 2);
|
||||
apply();
|
||||
manualPosition = false; placement = 'fit'; position(true);
|
||||
},
|
||||
state: () => ({ scale, x, y }),
|
||||
layoutReady() { if (initialFit) controller.fit(); },
|
||||
hasManualPosition: () => manualPosition,
|
||||
layoutReady() { if (placement) position(placement === 'fit'); },
|
||||
};
|
||||
const actions: [string, () => void][] = [
|
||||
['확대', () => controller.zoomTo(scale * config.step)],
|
||||
['축소', () => controller.zoomTo(scale / config.step)],
|
||||
['100% 복원', () => { initialFit = false; scale = 1; x = 16; y = 16; apply(); }],
|
||||
['Fit to View', () => { initialFit = true; controller.fit(); }],
|
||||
['100% 복원', () => { preservePosition(); scale = 1; x = 16; y = 16; apply(); }],
|
||||
['Fit to View', () => controller.fit()],
|
||||
];
|
||||
toolbar.setAttribute('role', 'group');
|
||||
toolbar.setAttribute('aria-label', '조직도 확대·이동 도구');
|
||||
@@ -69,9 +100,10 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
|
||||
}
|
||||
toolbar.append(output);
|
||||
const hint = document.createElement('span');
|
||||
hint.textContent = '드래그 이동 · Ctrl/⌘ + 휠 확대 · 방향키 이동';
|
||||
hint.textContent = [panEnabled ? '드래그·방향키 이동' : '', zoomEnabled ? 'Ctrl/⌘ + 휠 확대' : ''].filter(Boolean).join(' · ');
|
||||
toolbar.append(hint);
|
||||
viewport.addEventListener('wheel', event => {
|
||||
if (!zoomEnabled) return;
|
||||
if (!event.ctrlKey && !event.metaKey) return;
|
||||
event.preventDefault();
|
||||
const box = viewport.getBoundingClientRect();
|
||||
@@ -80,12 +112,13 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
|
||||
viewport.addEventListener('keydown', event => {
|
||||
if (event.target !== viewport) return;
|
||||
const shifts: Record<string, [number, number]> = { ArrowLeft: [40, 0], ArrowRight: [-40, 0], ArrowUp: [0, 40], ArrowDown: [0, -40] };
|
||||
if (shifts[event.key]) { event.preventDefault(); controller.panBy(...shifts[event.key]!); }
|
||||
if (shifts[event.key]) { event.preventDefault(); if (panEnabled) controller.panBy(...shifts[event.key]!); }
|
||||
else if (event.key === '+' || event.key === '=') { event.preventDefault(); controller.zoomTo(scale * config.step); }
|
||||
else if (event.key === '-') { event.preventDefault(); controller.zoomTo(scale / config.step); }
|
||||
}, { signal });
|
||||
// Tab can reach cards outside a zoomed/panned forest. Reveal the focused
|
||||
// control without changing scale or using the viewport's hidden scroll area.
|
||||
// Keyboard focus visibility remains available when manual Pan is disabled.
|
||||
viewport.addEventListener('focusin', event => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof view.HTMLElement) || !forest.contains(target)) return;
|
||||
@@ -99,11 +132,12 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
|
||||
const right = left + viewport.clientWidth - 16, bottom = top + viewport.clientHeight - 16;
|
||||
const dx = box.left < left ? left - box.left : box.right > right ? right - box.right : 0;
|
||||
const dy = box.top < top ? top - box.top : box.bottom > bottom ? bottom - box.bottom : 0;
|
||||
if (dx || dy) controller.panBy(dx, dy);
|
||||
if (dx || dy) { preservePosition(); x += dx; y += dy; apply(); }
|
||||
});
|
||||
}, { signal });
|
||||
viewport.addEventListener('pointerdown', event => {
|
||||
if (event.button !== 0 || event.isPrimary === false || (event.target as Element).closest('button,a')) return;
|
||||
if (!panEnabled) return;
|
||||
if (event.button !== 0 || event.isPrimary === false || (event.target as Element).closest('button,a,.hm-orgchart-employee')) return;
|
||||
suppressClick = false;
|
||||
drag = { id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
|
||||
}, { signal });
|
||||
@@ -123,7 +157,7 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
|
||||
};
|
||||
viewport.addEventListener('pointerup', finish, { signal });
|
||||
viewport.addEventListener('pointercancel', finish, { signal });
|
||||
viewport.addEventListener('lostpointercapture', () => { drag = undefined; delete viewport.dataset.dragging; }, { signal });
|
||||
viewport.addEventListener('lostpointercapture', event => { if (event.target === viewport) { drag = undefined; delete viewport.dataset.dragging; } }, { signal });
|
||||
// Release outside the viewport must not leave a pending drag.
|
||||
view.addEventListener('pointerup', finish, { signal });
|
||||
viewport.addEventListener('click', event => {
|
||||
@@ -133,7 +167,7 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
|
||||
if (event.detail === 0) return;
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
}, { capture: true, signal });
|
||||
const observer = new view.ResizeObserver(() => { if (initialFit) controller.fit(); });
|
||||
const observer = new view.ResizeObserver(() => { if (placement) position(placement === 'fit'); });
|
||||
observer.observe(viewport);
|
||||
signal.addEventListener('abort', () => { observer.disconnect(); drag = undefined; if (focusFrame) view.cancelAnimationFrame(focusFrame); }, { once: true });
|
||||
apply();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { calculateSummary, normalizeOrgChartDataset, validateOrgChartDataset } from '../data/index.js';
|
||||
import type { OrgChartDataSource, ValidationIssue } from '../data/index.js';
|
||||
import { applyOrgChartMove, calculateSummary, createOrgChartMoveValidator, normalizeOrgChartDataset, validateOrgChartDataset } from '../data/index.js';
|
||||
import type { OrgChartDataset, OrgChartMoveRequest, OrgChartMoveResult, OrgChartMoveValidation, OrgChartDataSource, ValidationIssue } from '../data/index.js';
|
||||
import { createEngineAdapter, loadEngine } from './engine.js';
|
||||
import type { EngineAdapter, EngineConstructor } from './engine.js';
|
||||
import { decorateNode, engineTree } from './render.js';
|
||||
@@ -9,6 +9,8 @@ import { createExpandedOrgChartContent } from './expanded-content.js';
|
||||
import { getCompanyColorPreset } from '../company-color-presets.js';
|
||||
import { resolveExpandedCompanies, validateTopNodes } from './expanded-company.js';
|
||||
import { createExpandedViewport, type ExpandedViewport } from './expanded-viewport.js';
|
||||
import { createExpandedEditing, type ExpandedEditing } from './expanded-editing.js';
|
||||
import { applyInitialVisibility, autoEngineOptions, resolveAutoLayout, resolveInteractions } from './auto-layout-policy.js';
|
||||
import { createPositionSummary } from './position-summary.js';
|
||||
import { createAffiliationSummary } from './affiliation-summary.js';
|
||||
import { createStatusSummary } from './status-summary.js';
|
||||
@@ -54,6 +56,13 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
if (options.layoutMode !== undefined && options.layoutMode !== 'fixed' && options.layoutMode !== 'auto') throw new TypeError('layoutMode must be "fixed" or "auto"');
|
||||
const layoutMode = options.layoutMode ?? 'fixed';
|
||||
const expanded = view === 'composition' && layoutMode === 'auto';
|
||||
if (expanded && options.dragDropEnabled !== undefined && typeof options.dragDropEnabled !== 'boolean') throw new TypeError('dragDropEnabled must be a boolean');
|
||||
let dragDropEnabled = expanded && (options.dragDropEnabled ?? false);
|
||||
let expandedEditing: ExpandedEditing | undefined;
|
||||
let autoLayout = resolveAutoLayout(expanded ? options.autoLayout : undefined);
|
||||
let interactionOptions = resolveInteractions(expanded ? options.interactions : undefined);
|
||||
let resetVisibility = false;
|
||||
let renderedLayoutKey: string | undefined;
|
||||
const expandedCompanyPreset = expanded ? getCompanyColorPreset(options.companyId ?? 'hanmac') : undefined;
|
||||
if (expanded && !expandedCompanyPreset) throw new TypeError('companyId must be a registered company preset in Auto mode');
|
||||
const topNodes = expanded ? freeze(structuredClone(options.companyTopNodes ?? {})) : {};
|
||||
@@ -67,15 +76,22 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
};
|
||||
let selectedCompanies = expanded ? copySelection(options.selectedCompanyIds) : undefined;
|
||||
let availableCompanies: string[] = [];
|
||||
let latestData: unknown;
|
||||
let latestData: OrgChartDataset | undefined;
|
||||
let moveValidator: ((request: OrgChartMoveRequest) => OrgChartMoveValidation) | undefined;
|
||||
let moveMessage = '';
|
||||
let moveStatus = 'idle';
|
||||
let blockPointerClick = false;
|
||||
let focusEmployee: string | undefined;
|
||||
const collapsedOrganizations = new Set<string>();
|
||||
const initializedOrganizations = new Set<string>();
|
||||
let expandedViewport: ExpandedViewport | undefined;
|
||||
let fitNext = true;
|
||||
let focusOrganization: string | undefined;
|
||||
const readOnly = view === 'composition' && options.displayMode === 'readOnly';
|
||||
const organizationLinks: Record<string, string> = Object.create(null);
|
||||
if (options.onEmployeeClick !== undefined && typeof options.onEmployeeClick !== 'function') throw new TypeError('onEmployeeClick must be a function');
|
||||
const employeeClickCallback = options.onEmployeeClick;
|
||||
if (options.onMove !== undefined && typeof options.onMove !== 'function') throw new TypeError('onMove must be a function');
|
||||
let employeeClickCallback = options.onEmployeeClick;
|
||||
for (const [id, value] of Object.entries(options.organizationLinks ?? {})) {
|
||||
if (value === undefined || value === '') continue;
|
||||
if (typeof value !== 'string') throw new TypeError('organizationLinks values must be URL strings');
|
||||
@@ -131,6 +147,49 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
if (!container) throw new Error('Mount OrgChart before update or reload');
|
||||
}
|
||||
|
||||
const moveRequest = (source: HTMLElement, target: HTMLElement): OrgChartMoveRequest => ({
|
||||
kind: source.matches('.hm-orgchart-employee') ? 'employee' : 'organization',
|
||||
sourceId: source.dataset.employeeId ?? source.dataset.organizationId!, targetOrganizationId: target.dataset.organizationId!,
|
||||
});
|
||||
function validateMove(request: OrgChartMoveRequest): OrgChartMoveValidation {
|
||||
if (!dragDropEnabled) return { allowed: false, code: 'EDITING_DISABLED', message: 'Drag & Drop이 OFF입니다. 조직 이동을 사용하려면 ON으로 설정하세요.' };
|
||||
// An explicitly configured company top card also owns a protected reference.
|
||||
if (request?.kind === 'employee' && Object.values(topNodes).some(top => top.employeeId === request.sourceId)) {
|
||||
return { allowed: false, code: 'LEADER_PROTECTED', message: '회사 상단에 지정된 직원은 상단 직원 지정 정책을 먼저 변경해야 이동할 수 있습니다.' };
|
||||
}
|
||||
return moveValidator?.(request) ?? { allowed: false, code: 'INVALID_DATA', message: '조직 데이터를 불러온 뒤 이동하세요.' };
|
||||
}
|
||||
function notifyMove(result: OrgChartMoveResult): void {
|
||||
try { options.onMove?.(result); } catch { /* Host callbacks never own the Core transaction. */ }
|
||||
}
|
||||
async function move(request: OrgChartMoveRequest): Promise<OrgChartMoveResult> {
|
||||
assertMounted();
|
||||
if (!expanded) throw new Error('Organization editing is available only in Auto mode');
|
||||
const policy = validateMove(request);
|
||||
let result: OrgChartMoveResult = !latestData || state.status !== 'ready'
|
||||
? { moved: false, code: 'INVALID_DATA', message: '조직 데이터를 불러온 뒤 이동하세요.' }
|
||||
: !policy.allowed ? { moved: false, code: policy.code, message: policy.message }
|
||||
: applyOrgChartMove(latestData, request);
|
||||
if (!result.moved) {
|
||||
moveMessage = `이동 실패 · ${result.message}`; moveStatus = 'failure';
|
||||
const feedback = content?.querySelector<HTMLElement>('.hm-orgchart-move-feedback');
|
||||
if (feedback) { feedback.textContent = moveMessage; feedback.dataset.moveStatus = moveStatus; }
|
||||
notifyMove(result); return result;
|
||||
}
|
||||
const label = request.kind === 'employee' ? result.data.employees.find(item => item.id === request.sourceId)?.name
|
||||
: result.data.organizations.find(item => item.id === request.sourceId)?.name;
|
||||
const target = result.data.organizations.find(item => item.id === request.targetOrganizationId)!;
|
||||
moveMessage = `이동 완료 · ${label} → ${target.name}`; moveStatus = 'success';
|
||||
focusEmployee = request.kind === 'employee' ? request.sourceId : undefined;
|
||||
focusOrganization = request.targetOrganizationId; fitNext = false;
|
||||
const { version } = begin();
|
||||
try { await apply(result.data, version); }
|
||||
catch { fail(version, [{ code: 'CORE_RENDER', path: 'core', message: 'Move rerender failed' }]); }
|
||||
if (!active(version) || state.status !== 'ready') result = { moved: false, code: 'INVALID_DATA', message: '이동 중 다른 데이터 요청이 시작되었거나 화면을 갱신할 수 없습니다.' };
|
||||
if (active(version)) notifyMove(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function begin(): { version: number; signal: AbortSignal } {
|
||||
request?.abort();
|
||||
const Abort = container?.ownerDocument.defaultView?.AbortController ?? AbortController;
|
||||
@@ -147,12 +206,14 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
employeeDialog?.close();
|
||||
contentGeneration?.abort();
|
||||
contentGeneration = undefined;
|
||||
expandedEditing = undefined;
|
||||
engine?.dispose();
|
||||
engine = undefined;
|
||||
renderContext = undefined;
|
||||
viewport = undefined;
|
||||
expandedViewport = undefined;
|
||||
latestData = undefined;
|
||||
moveValidator = undefined;
|
||||
content?.replaceChildren();
|
||||
}
|
||||
|
||||
@@ -190,6 +251,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
card.tabIndex = 0;
|
||||
card.setAttribute('role', 'button');
|
||||
if (!employeeClickCallback) card.setAttribute('aria-haspopup', 'dialog');
|
||||
else card.removeAttribute('aria-haspopup');
|
||||
const name = card.querySelector('.hm-orgchart-person-name')?.textContent ?? '';
|
||||
const position = card.querySelector('.hm-orgchart-person-role')?.textContent ?? '';
|
||||
card.setAttribute('aria-label', `${name} ${position} 직원 정보 보기`);
|
||||
@@ -230,16 +292,17 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
const issues = [...companyState.issues, ...validateTopNodes(data, normalized.roots, companyState.companies, topNodes)];
|
||||
if (issues.length) { fail(version, issues); return; }
|
||||
availableCompanies = companyState.companyIds;
|
||||
const validIds = new Set(data.organizations.map(org => org.id));
|
||||
for (const id of collapsedOrganizations) if (!validIds.has(id)) collapsedOrganizations.delete(id);
|
||||
applyInitialVisibility(data.organizations, collapsedOrganizations, initializedOrganizations, autoLayout.initialVisibleDepth, resetVisibility);
|
||||
resetVisibility = false;
|
||||
latestData = data;
|
||||
moveValidator = createOrgChartMoveValidator(data);
|
||||
}
|
||||
const summary = freeze(calculateSummary(data, policy));
|
||||
const topLeaderIds = new Set(view === 'composition' ? normalized.roots.filter(node => node.organization.type === 'root' && node.organization.leaderId).map(node => node.organization.leaderId!) : []);
|
||||
const nextContext: RenderContext = { data, summary, orderedOrganizations: [], employeesByOrganization: normalized.employeesByOrganization, topLeaderIds, readOnly: readOnly || expanded, organizationLinks, expandedCompanyPreset,
|
||||
organizationCompanies: companyState?.companies, collapsedOrganizations: expanded ? collapsedOrganizations : undefined,
|
||||
companyTopNodes: topNodes, employeeConnections, toggleExpanded: id => {
|
||||
if (destroyed || !latestData) return;
|
||||
companyTopNodes: topNodes, employeeConnections, autoLayout: expanded ? autoLayout : undefined, interactions: expanded ? interactionOptions : undefined, toggleExpanded: id => {
|
||||
if (destroyed || !latestData || !interactionOptions.collapse) return;
|
||||
if (collapsedOrganizations.has(id)) collapsedOrganizations.delete(id); else collapsedOrganizations.add(id);
|
||||
focusOrganization = id; fitNext = false;
|
||||
const { version } = begin();
|
||||
@@ -264,6 +327,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
if (LoadedEngine) Engine = LoadedEngine;
|
||||
const document = content!.ownerDocument;
|
||||
const previousViewport = expandedViewport?.state();
|
||||
const previousManualPosition = expandedViewport?.hasManualPosition();
|
||||
employeeDialog?.close();
|
||||
contentGeneration?.abort();
|
||||
const Abort = document.defaultView?.AbortController ?? AbortController;
|
||||
@@ -275,10 +339,18 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
}
|
||||
const activeElement = root!.shadowRoot?.activeElement;
|
||||
if (needsEngine) {
|
||||
const layoutKey = expanded ? `${autoLayout.type}:${autoLayout.hybridStartDepth}` : 'fixed';
|
||||
// Upstream init merges old options. Rebuild only the engine when changing
|
||||
// direction/vertical policy so obsolete verticalLevel cannot survive LR.
|
||||
if (engine && renderedLayoutKey !== layoutKey) { engine.dispose(); engine = undefined; }
|
||||
if (engine) engine.update(tree);
|
||||
else engine = createEngineAdapter(Engine!, viewport!, tree, (node, value, signal) => {
|
||||
if (renderContext) decorateNode(node, value, renderContext, signal, target => engine?.toggle(target));
|
||||
});
|
||||
}, expanded ? autoEngineOptions(autoLayout) : undefined, expanded ? {
|
||||
canDrop: (source, target) => validateMove(moveRequest(source, target)).allowed,
|
||||
onDrop: (source, target) => { void move(moveRequest(source, target)); },
|
||||
} : undefined);
|
||||
renderedLayoutKey = layoutKey;
|
||||
}
|
||||
// If regeneration removed keyboard focus, offer the stable viewport next.
|
||||
const nextContent = view === 'composition' ? expanded
|
||||
@@ -292,8 +364,25 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'hm-orgchart-expanded-toolbar';
|
||||
nextContent.insertBefore(toolbar, viewport!);
|
||||
expandedViewport = createExpandedViewport(viewport!, toolbar, contentGeneration.signal, zoom, previousViewport, fitNext);
|
||||
expandedViewport = createExpandedViewport(viewport!, toolbar, contentGeneration.signal,
|
||||
{ ...zoom, zoomEnabled: interactionOptions.zoom, panEnabled: interactionOptions.pan }, previousViewport, fitNext, previousManualPosition);
|
||||
fitNext = false;
|
||||
const feedback = document.createElement('p');
|
||||
feedback.className = 'hm-orgchart-move-feedback'; feedback.id = `${prefix}-move-feedback`;
|
||||
feedback.setAttribute('role', 'status'); feedback.setAttribute('aria-live', 'polite'); feedback.setAttribute('aria-atomic', 'true');
|
||||
feedback.dataset.moveStatus = moveStatus;
|
||||
feedback.textContent = moveMessage || (dragDropEnabled
|
||||
? 'Drag & Drop ON · 카드/제목: 이동 · Alt+M: 키보드 이동 · 빈 공간: Pan'
|
||||
: 'Drag & Drop OFF · 직원 정보·그룹 접기·빈 공간 Pan 사용 가능');
|
||||
nextContent.insertBefore(feedback, viewport!);
|
||||
viewport!.dataset.dragDropEnabled = String(dragDropEnabled);
|
||||
expandedEditing = createExpandedEditing(viewport!, feedback, contentGeneration.signal, engine!, validateMove,
|
||||
request => { void move(request); }, () => { blockPointerClick = true; }, dragDropEnabled);
|
||||
for (const source of nextContent.querySelectorAll<HTMLElement>('.hm-orgchart-employee, .hm-orgchart-expanded-toggle')) {
|
||||
if (dragDropEnabled) source.setAttribute('aria-keyshortcuts', 'Alt+M');
|
||||
else source.removeAttribute('aria-keyshortcuts');
|
||||
source.setAttribute('aria-describedby', feedback.id);
|
||||
}
|
||||
const selection = document.createElement('p');
|
||||
selection.className = 'hm-orgchart-expanded-selection';
|
||||
selection.setAttribute('aria-live', 'polite');
|
||||
@@ -311,7 +400,9 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
prepareEmployeeCards(nextContent);
|
||||
content!.replaceChildren(nextContent);
|
||||
const restoreFocus = !!activeElement && (activeElement === viewport || !activeElement.isConnected);
|
||||
const focusTarget = focusOrganization ? [...content!.querySelectorAll<HTMLButtonElement>('.hm-orgchart-expanded-toggle')].find(button => button.dataset.organizationId === focusOrganization) : undefined;
|
||||
const focusTarget = (focusEmployee ? [...content!.querySelectorAll<HTMLElement>('.hm-orgchart-employee')].find(card => card.dataset.employeeId === focusEmployee) : undefined)
|
||||
?? (focusOrganization ? [...content!.querySelectorAll<HTMLButtonElement>('.hm-orgchart-expanded-toggle')].find(button => button.dataset.organizationId === focusOrganization) : undefined);
|
||||
focusEmployee = undefined;
|
||||
focusOrganization = undefined;
|
||||
if (focusTarget) focusTarget.focus({ preventScroll: true });
|
||||
else if (restoreFocus) (expanded ? viewport! : readOnly ? shell! : viewport ?? content!.querySelector<HTMLElement>('.hm-orgchart-summary-scroll') ?? shell!).focus({ preventScroll: true });
|
||||
@@ -323,7 +414,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
|
||||
async function reload(nextSource?: OrgChartDataSource): Promise<void> {
|
||||
assertMounted();
|
||||
if (expanded) fitNext = true;
|
||||
if (expanded) { fitNext = true; moveMessage = ''; moveStatus = 'idle'; }
|
||||
if (nextSource) {
|
||||
if (typeof nextSource.load !== 'function') throw new TypeError('An OrgChartDataSource is required');
|
||||
source = nextSource;
|
||||
@@ -391,6 +482,14 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
const Abort = document.defaultView?.AbortController ?? AbortController;
|
||||
interactions = new Abort();
|
||||
employeeDialog = createEmployeeInfoDialog(shell, interactions.signal, prefix, readOnly);
|
||||
if (expanded) {
|
||||
// This capture guard survives the generation replaced by a successful drop.
|
||||
shell.addEventListener('pointerdown', () => { blockPointerClick = false; }, { capture: true, signal: interactions.signal });
|
||||
shell.addEventListener('click', event => {
|
||||
if (!blockPointerClick || event.detail === 0) return;
|
||||
blockPointerClick = false; event.preventDefault(); event.stopImmediatePropagation();
|
||||
}, { capture: true, signal: interactions.signal });
|
||||
}
|
||||
content.addEventListener('click', activateEmployee, { signal: interactions.signal });
|
||||
content.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') activateEmployee(event);
|
||||
@@ -399,6 +498,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
},
|
||||
async update(input) {
|
||||
assertMounted();
|
||||
if (expanded) fitNext = true;
|
||||
// Capture before begin() invokes callbacks that can mutate consumer input.
|
||||
let captured: unknown;
|
||||
try { captured = snapshot(input); }
|
||||
@@ -444,8 +544,58 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
if (latestData) { const { version } = begin(); await apply(latestData, version); }
|
||||
},
|
||||
getSelectedCompanies() { return Object.freeze([...(selectedCompanies ?? availableCompanies)]); },
|
||||
zoomTo(scale) { expandedViewport?.zoomTo(scale); },
|
||||
panBy(x, y) { expandedViewport?.panBy(x, y); },
|
||||
async setAutoLayout(patch) {
|
||||
assertMounted();
|
||||
if (!expanded) throw new Error('Auto layout configuration is available only in Auto mode');
|
||||
const next = resolveAutoLayout({ ...autoLayout, ...patch });
|
||||
resetVisibility = resetVisibility || next.initialVisibleDepth !== autoLayout.initialVisibleDepth;
|
||||
autoLayout = next; fitNext = true;
|
||||
if (latestData) { const { version } = begin(); await apply(latestData, version); }
|
||||
},
|
||||
getAutoLayout() { return autoLayout; },
|
||||
setEmployeeClickHandler(handler) {
|
||||
assertMounted();
|
||||
if (!expanded) throw new Error('Popup ownership changes are available only in Auto mode');
|
||||
if (handler !== undefined && typeof handler !== 'function') throw new TypeError('Employee click handler must be a function or undefined');
|
||||
employeeDialog?.close();
|
||||
employeeClickCallback = handler;
|
||||
if (content) prepareEmployeeCards(content);
|
||||
},
|
||||
async setInteractions(patch) {
|
||||
assertMounted();
|
||||
if (!expanded) throw new Error('Interaction configuration is available only in Auto mode');
|
||||
interactionOptions = resolveInteractions({ ...interactionOptions, ...patch });
|
||||
fitNext = false;
|
||||
if (latestData) { const { version } = begin(); await apply(latestData, version); }
|
||||
},
|
||||
getInteractions() { return interactionOptions; },
|
||||
setDragDropEnabled(enabled) {
|
||||
assertMounted();
|
||||
if (!expanded) throw new Error('Drag & Drop configuration is available only in Auto mode');
|
||||
if (typeof enabled !== 'boolean') throw new TypeError('dragDropEnabled must be a boolean');
|
||||
if (enabled === dragDropEnabled) return;
|
||||
dragDropEnabled = enabled;
|
||||
expandedEditing?.setEnabled(enabled);
|
||||
if (viewport) viewport.dataset.dragDropEnabled = String(enabled);
|
||||
const feedback = content?.querySelector<HTMLElement>('.hm-orgchart-move-feedback');
|
||||
const cancelled = feedback?.dataset.moveStatus === 'cancelled';
|
||||
moveMessage = ''; moveStatus = cancelled ? 'cancelled' : 'idle';
|
||||
if (feedback) {
|
||||
feedback.dataset.moveStatus = moveStatus;
|
||||
feedback.textContent = (cancelled ? '이동을 취소했습니다. ' : '') + (enabled
|
||||
? 'Drag & Drop ON · 카드/제목: 이동 · Alt+M: 키보드 이동 · 빈 공간: Pan'
|
||||
: 'Drag & Drop OFF · 직원 정보·그룹 접기·빈 공간 Pan 사용 가능');
|
||||
}
|
||||
for (const source of content?.querySelectorAll<HTMLElement>('.hm-orgchart-employee, .hm-orgchart-expanded-toggle') ?? []) {
|
||||
if (enabled) source.setAttribute('aria-keyshortcuts', 'Alt+M');
|
||||
else source.removeAttribute('aria-keyshortcuts');
|
||||
}
|
||||
},
|
||||
getDragDropEnabled() { return dragDropEnabled; },
|
||||
move,
|
||||
getData() { return latestData; },
|
||||
zoomTo(scale) { if (interactionOptions.zoom) expandedViewport?.zoomTo(scale); },
|
||||
panBy(x, y) { if (interactionOptions.pan) expandedViewport?.panBy(x, y); },
|
||||
fitToView() { expandedViewport?.fit(); },
|
||||
getViewportState() { return expandedViewport?.state(); },
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createOrganizationTotals } from './organization-totals.js';
|
||||
import { createAffiliationBar } from './affiliation-bar.js';
|
||||
import { applyWorkdeskLayout } from './workdesk-layout.js';
|
||||
import { addWorkdeskArtwork } from './workdesk-artwork.js';
|
||||
import { workdeskCanvas } from './workdesk-layout.js';
|
||||
|
||||
type SummaryComponent = 'PositionSummary' | 'AffiliationSummary' | 'StatusSummary';
|
||||
type ContentSlot = 'side' | 'footer' | 'belowTree';
|
||||
@@ -115,6 +116,11 @@ export function createOrgChartContent(
|
||||
if (slots.side.childElementCount) stage.append(slots.side);
|
||||
else content.classList.add('hm-orgchart-composition-no-side');
|
||||
if (slots.footer.childElementCount) stage.append(slots.footer);
|
||||
if (stage.dataset.workdeskArtwork) {
|
||||
heading.dataset.compositionRegion = slots.side.dataset.compositionRegion = 'top';
|
||||
main.querySelector<HTMLElement>('.hm-orgchart-axis')!.dataset.compositionRegion = tree.dataset.compositionRegion = 'center';
|
||||
slots.belowTree.dataset.compositionRegion = slots.footer.dataset.compositionRegion = 'bottom';
|
||||
}
|
||||
observeStageScale(content, stage, signal, readOnly);
|
||||
return content;
|
||||
}
|
||||
@@ -129,7 +135,7 @@ function observeStageScale(wrapper: HTMLElement, stage: HTMLElement, signal: Abo
|
||||
const view = wrapper.ownerDocument.defaultView;
|
||||
if (!view) return;
|
||||
let frame: number | undefined;
|
||||
let applied = { scale: NaN, width: NaN, height: NaN };
|
||||
let applied = { scale: NaN, width: NaN, height: NaN, canvasHeight: NaN, centerOffset: 0 };
|
||||
const ancestors: HTMLElement[] = [];
|
||||
const collectAncestors = () => {
|
||||
// The constructor runs before insertion; discover the real host only once
|
||||
@@ -175,23 +181,56 @@ function observeStageScale(wrapper: HTMLElement, stage: HTMLElement, signal: Abo
|
||||
collectAncestors();
|
||||
const available = wrapper.clientWidth; // integer floor: never overflows the host
|
||||
const baseWidth = stage.offsetWidth;
|
||||
const baseHeight = stage.offsetHeight;
|
||||
const adaptive = fitHeight && stage.dataset.workdeskArtwork === 'true';
|
||||
const baseHeight = adaptive ? workdeskCanvas.height : stage.offsetHeight;
|
||||
if (!available || !baseWidth || !baseHeight) return;
|
||||
let scale = Math.min(1, available / baseWidth);
|
||||
const availableHeight = fitHeight ? heightLimit() : baseHeight * scale;
|
||||
if (fitHeight) {
|
||||
const availableHeight = heightLimit();
|
||||
scale = Math.min(scale, availableHeight / baseHeight);
|
||||
}
|
||||
if (Math.abs(scale - applied.scale) < 0.0005 && baseWidth === applied.width && baseHeight === applied.height) return;
|
||||
if (!adaptive && Math.abs(scale - applied.scale) < 0.0005 && baseWidth === applied.width && baseHeight === applied.height) return;
|
||||
if (adaptive && !scale) return;
|
||||
const canvasHeight = adaptive ? Math.max(baseHeight, availableHeight / scale) : baseHeight;
|
||||
const scaleChanged = scale !== applied.scale;
|
||||
applied = { scale, width: baseWidth, height: baseHeight };
|
||||
stage.style.transform = scale < 1 ? `scale(${scale})` : '';
|
||||
wrapper.style.height = `${baseHeight * scale}px`;
|
||||
if (Math.abs(scale - applied.scale) >= 0.000001 || baseWidth !== applied.width || canvasHeight !== applied.canvasHeight) {
|
||||
stage.style.transform = scale < 1 ? `scale(${scale})` : '';
|
||||
if (adaptive) stage.style.setProperty('--hm-orgchart-workdesk-stage-height', `${canvasHeight}px`);
|
||||
wrapper.style.height = `${canvasHeight * scale}px`;
|
||||
}
|
||||
let centerOffset = 0;
|
||||
if (adaptive && canvasHeight > baseHeight + 0.01) {
|
||||
const origin = stage.getBoundingClientRect();
|
||||
const local = (element: Element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { top: (rect.top - origin.top) / scale, bottom: (rect.bottom - origin.top) / scale };
|
||||
};
|
||||
const center = [...stage.querySelectorAll('.hm-orgchart-axis .hm-orgchart-employee,.hm-orgchart-hub,.hm-orgchart-viewport .hm-orgchart-employee,.hm-orgchart-viewport .hm-orgchart-organization-name,image[data-arrow]')]
|
||||
.filter(element => element.getClientRects().length).map(local);
|
||||
const top = [...stage.querySelectorAll('[data-composition-region="top"]')].map(local);
|
||||
const bottom = [...stage.querySelectorAll('[data-composition-region="bottom"]')].filter(element => element.childElementCount).map(local);
|
||||
if (center.length && top.length) {
|
||||
const start = Math.min(...center.map(rect => rect.top)) - applied.centerOffset;
|
||||
const end = Math.max(...center.map(rect => rect.bottom)) - applied.centerOffset;
|
||||
const bottomAnchor = bottom.length ? Math.min(...bottom.map(rect => rect.top)) : canvasHeight - (parseFloat(view.getComputedStyle(stage).paddingBottom) || 0);
|
||||
const target = (Math.max(...top.map(rect => rect.bottom)) + bottomAnchor - start - end) / 2;
|
||||
// Keep the baseline intact when the screen is short; share only newly
|
||||
// available height until the entire central chart fits between anchors.
|
||||
centerOffset = Math.max(0, Math.min(canvasHeight - baseHeight, target));
|
||||
}
|
||||
}
|
||||
if (adaptive && (Math.abs(centerOffset - applied.centerOffset) > 0.01 || !Number.isFinite(applied.scale))) {
|
||||
stage.style.setProperty('--hm-orgchart-workdesk-center-offset', `${centerOffset.toFixed(4)}px`);
|
||||
} else centerOffset = applied.centerOffset;
|
||||
const positionChanged = adaptive && (Math.abs(canvasHeight - applied.canvasHeight) > 0.01 || Math.abs(centerOffset - applied.centerOffset) > 0.01);
|
||||
applied = { scale, width: baseWidth, height: baseHeight, canvasHeight, centerOffset };
|
||||
wrapper.dataset.scale = scale.toFixed(4);
|
||||
wrapper.dataset.baseWidth = String(baseWidth);
|
||||
wrapper.dataset.baseHeight = String(baseHeight);
|
||||
// Open tooltips were positioned from pre-scale geometry; let them close.
|
||||
if (scaleChanged) wrapper.dispatchEvent(new view.CustomEvent('hm-orgchart-scale', { bubbles: true }));
|
||||
if (adaptive) { wrapper.dataset.canvasHeight = String(canvasHeight); wrapper.dataset.centerOffset = centerOffset.toFixed(4); }
|
||||
// Open tooltips use the old card bounds. A bounded parent can move the
|
||||
// central chart without changing scale or emitting a window resize.
|
||||
if (scaleChanged || positionChanged) wrapper.dispatchEvent(new view.CustomEvent('hm-orgchart-scale', { bubbles: true }));
|
||||
};
|
||||
const schedule = () => { if (frame === undefined && !signal.aborted) frame = view.requestAnimationFrame(apply); };
|
||||
const observer = new view.ResizeObserver(schedule);
|
||||
|
||||
@@ -6,7 +6,8 @@ import { getDepartmentCardAsset } from './department-card-assets.js';
|
||||
import type { OrgChartOrganizationLinks } from './types.js';
|
||||
import type { CompanyColorPreset } from '../company-color-presets.js';
|
||||
import { decorateExpandedNode } from './expanded-content.js';
|
||||
import type { OrgChartConnectionDirection, OrgChartTopNode } from './types.js';
|
||||
import type { OrgChartConnectionDirection, OrgChartTopNode, OrgChartInteractionOptions } from './types.js';
|
||||
import type { ResolvedAutoLayout } from './auto-layout-policy.js';
|
||||
|
||||
const organizationNames = { root: '최상위 조직', division: '부문', office: '실', div: 'Div', cell: '셀', team: '팀' } as const;
|
||||
|
||||
@@ -25,6 +26,8 @@ export interface RenderContext {
|
||||
collapsedOrganizations?: ReadonlySet<string>;
|
||||
companyTopNodes?: Readonly<Record<string, OrgChartTopNode>>;
|
||||
employeeConnections?: Readonly<Record<string, OrgChartConnectionDirection>>;
|
||||
autoLayout?: ResolvedAutoLayout;
|
||||
interactions?: Readonly<Required<OrgChartInteractionOptions>>;
|
||||
toggleExpanded?: (organizationId: string) => void;
|
||||
}
|
||||
|
||||
@@ -35,6 +38,10 @@ export function engineTree(roots: OrganizationTreeNode[], prefix: string, contex
|
||||
context.orderedOrganizations.push(tree.organization);
|
||||
return {
|
||||
id: `${prefix}-org-${index}`, name: '', organizationIndex: index, depth, groupVariant: variant,
|
||||
// Make public (root=Depth 1) semantics explicit rather than depending on
|
||||
// upstream's detached-DOM ancestor inference during mode reconstruction.
|
||||
...(context.autoLayout ? { level: depth + 1 } : {}),
|
||||
...(context.autoLayout?.type === 'tb' && context.autoLayout.hybridOrganizationIds.includes(tree.organization.id) ? { hybrid: true } : {}),
|
||||
...(tree.children.length && !context.collapsedOrganizations?.has(tree.organization.id) ? { children: tree.children.map((child, childIndex) => convert(child, depth + 1, context.readOnly ? variant : depth === startDepth ? childIndex % 6 : variant)) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Affiliation, ApprovedAggregationPolicy, Employee, OrgChartDataSource, OrgChartSummary, Organization, ValidationIssue } from '../data/index.js';
|
||||
import type { Affiliation, ApprovedAggregationPolicy, Employee, OrgChartDataSource, OrgChartDataset, OrgChartMoveRequest, OrgChartMoveResult, OrgChartSummary, Organization, ValidationIssue } from '../data/index.js';
|
||||
|
||||
export type OrgChartState =
|
||||
| { readonly status: 'idle' | 'loading' | 'empty' | 'destroyed' }
|
||||
@@ -14,6 +14,27 @@ export type OrgChartDisplayMode = 'interactive' | 'readOnly';
|
||||
* with natural overflow; Fixed preserves the Workdesk composition. */
|
||||
export type OrgChartLayoutMode = 'fixed' | 'auto';
|
||||
|
||||
/** Independent Auto presentation policies. Root organizations are Depth 1. */
|
||||
export type OrgChartAutoLayoutType = 'tb' | 'lr' | 'hybrid';
|
||||
export interface OrgChartAutoLayoutOptions {
|
||||
type?: OrgChartAutoLayoutType;
|
||||
/** Hybrid only. The first displayed depth rendered as parent-local lists. */
|
||||
hybridStartDepth?: 2 | 3 | 4;
|
||||
/** Initial expansion policy; changing it intentionally resets collapse state.
|
||||
* Later user toggles survive layout and data changes for valid organizations. */
|
||||
initialVisibleDepth?: 1 | 2 | 3 | 'all';
|
||||
/** TB only. Each listed organization's descendants use a local vertical list.
|
||||
* Cannot be combined with LR or global Hybrid. */
|
||||
hybridOrganizationIds?: readonly string[];
|
||||
}
|
||||
export interface OrgChartInteractionOptions {
|
||||
/** Auto only; each optional feature defaults to enabled and is independent. */
|
||||
collapse?: boolean;
|
||||
zoom?: boolean;
|
||||
/** Disables manual pointer/arrow/API Pan; keyboard focus reveal stays available. */
|
||||
pan?: boolean;
|
||||
}
|
||||
|
||||
/** Links are keyed by the existing organization ID. Only 1Depth titles use them. */
|
||||
export type OrgChartOrganizationLinks = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
@@ -52,8 +73,15 @@ export interface OrgChartOptions {
|
||||
companyId?: string;
|
||||
/** Auto only. undefined means all companies; [] is an explicit empty selection. */
|
||||
selectedCompanyIds?: readonly string[];
|
||||
/** Auto only; Fixed ignores these independent presentation options. */
|
||||
autoLayout?: OrgChartAutoLayoutOptions;
|
||||
interactions?: OrgChartInteractionOptions;
|
||||
/** Auto-only employee/group editing. Omission disables pointer, keyboard and
|
||||
* handle moves; Pan and employee information remain independent. Fixed ignores it. */
|
||||
dragDropEnabled?: boolean;
|
||||
companyTopNodes?: Readonly<Record<string, OrgChartTopNode>>;
|
||||
/** Actual staff connector route, default top. Leaders always connect from above. */
|
||||
/** Actual TB/Hybrid staff connector route, default top; their leaders connect
|
||||
* from above. LR always routes employees from the left. */
|
||||
employeeConnections?: Readonly<Record<string, OrgChartConnectionDirection>>;
|
||||
zoom?: { min?: number; max?: number; step?: number };
|
||||
/** No entry (or an empty URL) keeps an ordinary title. HTTP(S) and relative URLs are supported. */
|
||||
@@ -61,6 +89,9 @@ export interface OrgChartOptions {
|
||||
/** Optional host-owned information UI. When supplied, it replaces the default read-only dialog.
|
||||
* Called for every employee card in interactive and readOnly modes. */
|
||||
onEmployeeClick?: (employee: Employee, context: OrgChartEmployeeClickContext) => void;
|
||||
/** Auto-only browser-state editing. Called after a move or policy rejection;
|
||||
* never saves to the source or a server. Consumer callback errors are isolated. */
|
||||
onMove?: (result: OrgChartMoveResult) => void;
|
||||
onStateChange?: (state: OrgChartState) => void;
|
||||
}
|
||||
|
||||
@@ -75,6 +106,22 @@ export interface OrgChartHandle {
|
||||
/** Auto view state is separate from the immutable source. Fixed rejects selection changes. */
|
||||
setSelectedCompanies(ids?: readonly string[]): Promise<void>;
|
||||
getSelectedCompanies(): readonly string[];
|
||||
/** Merge an Auto-only presentation patch without remounting or losing valid collapse state. */
|
||||
setAutoLayout(options: OrgChartAutoLayoutOptions): Promise<void>;
|
||||
getAutoLayout(): Readonly<OrgChartAutoLayoutOptions>;
|
||||
/** Auto only. Change popup ownership without rebuilding or changing view state.
|
||||
* undefined restores the existing Core dialog. */
|
||||
setEmployeeClickHandler(handler?: OrgChartOptions['onEmployeeClick']): void;
|
||||
/** Auto only. Merge independent feature flags while retaining view state. */
|
||||
setInteractions(options: OrgChartInteractionOptions): Promise<void>;
|
||||
getInteractions(): Readonly<OrgChartInteractionOptions>;
|
||||
/** Auto only. Cancel active editing without reloading or resetting completed moves. */
|
||||
setDragDropEnabled(enabled: boolean): void;
|
||||
getDragDropEnabled(): boolean;
|
||||
/** Auto only. Uses the same policy as pointer and keyboard moves. */
|
||||
move(request: OrgChartMoveRequest): Promise<OrgChartMoveResult>;
|
||||
/** Immutable current Auto dataset, including hidden companies and local moves. */
|
||||
getData(): OrgChartDataset | undefined;
|
||||
zoomTo(scale: number): void;
|
||||
panBy(x: number, y: number): void;
|
||||
fitToView(): void;
|
||||
|
||||
+7
@@ -16,6 +16,10 @@ declare module 'orgchart' {
|
||||
draggable: boolean;
|
||||
exportButton: boolean;
|
||||
chartClass: string;
|
||||
direction?: 't2b' | 'l2r';
|
||||
verticalLevel?: number;
|
||||
visibleLevel?: number;
|
||||
dropCriteria?: (source: HTMLElement, parent: HTMLElement | null, target: HTMLElement) => boolean;
|
||||
nodeTemplate: () => string;
|
||||
createNode: (node: HTMLElement, data: EngineNode) => void;
|
||||
}
|
||||
@@ -34,5 +38,8 @@ declare module 'orgchart' {
|
||||
unbindPan(): void;
|
||||
unbindZoom(): void;
|
||||
detachMinimap(): void;
|
||||
filterAllowedDropNodes(source: HTMLElement): void;
|
||||
dropHandler(event: { dropZone: HTMLElement }): Promise<void>;
|
||||
dragendHandler(): void;
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,16 @@ export function addWorkdeskArtwork(stage: HTMLElement, hub: HTMLElement, signal:
|
||||
curves.setAttribute('transform', 'translate(162 202)');
|
||||
overlay.append(curves);
|
||||
for (const [id, d] of hubPaths) path(id, d, curves);
|
||||
path('representative-to-hub', 'M935 142V286').setAttribute('transform', 'translate(0 -17)');
|
||||
const representative = path('representative-to-hub', 'M935 142V286');
|
||||
representative.setAttribute('transform', 'translate(0 -17)');
|
||||
// Original Workdesk org-arrow-jang-head: a solid upward chevron, separate
|
||||
// from the five team PNG arrows and the dashed representative connector.
|
||||
const representativeHead = document.createElementNS(svgNamespace, 'path');
|
||||
representativeHead.dataset.arrowHead = 'representative';
|
||||
representativeHead.setAttribute('d', 'M0.00652573 5.54699C-0.0317927 5.092 0.0997532 4.66961 0.37176 4.32434L3.97332 0.436642C4.57335 -0.142366 5.40409 -0.144766 6.01239 0.424923L9.63348 4.33508C10.7765 5.85831 9.00989 7.82069 7.56414 6.63684L4.99774 3.88781L2.44207 6.62609C1.53083 7.44647 0.11378 6.8181 0.00652573 5.54699Z');
|
||||
representativeHead.setAttribute('fill', '#45639F');
|
||||
representativeHead.setAttribute('transform', 'translate(930 121.2969)');
|
||||
overlay.append(representativeHead);
|
||||
const business = path('hub-to-business-management', 'M834 204C650 204 250 235 105 256.2');
|
||||
for (const [id, source, x, y, width, height] of arrows) {
|
||||
const image = document.createElementNS(svgNamespace, 'image');
|
||||
@@ -86,10 +95,22 @@ export function addWorkdeskArtwork(stage: HTMLElement, hub: HTMLElement, signal:
|
||||
if (signal.aborted || !stage.isConnected) return;
|
||||
const header = stage.querySelector<HTMLElement>('.node[data-organization-id="business-management"] .hm-orgchart-organization-name');
|
||||
if (!header) return;
|
||||
const origin = stage.getBoundingClientRect();
|
||||
// The central chart and overlay move together inside the adaptive stage.
|
||||
// Measuring against the overlay avoids adding that offset a second time.
|
||||
const origin = overlay.getBoundingClientRect();
|
||||
const bounds = header.getBoundingClientRect();
|
||||
const scale = origin.width / stage.offsetWidth;
|
||||
const scale = origin.width / workdeskCanvas.width;
|
||||
if (!scale) return;
|
||||
const card = stage.querySelector<HTMLElement>('.hm-orgchart-top-leaders .hm-orgchart-employee');
|
||||
if (card) {
|
||||
const leader = card.getBoundingClientRect();
|
||||
const axis = (leader.left + leader.width / 2 - origin.left) / scale;
|
||||
// Attach the upward tip to the live card's bottom edge in the same
|
||||
// overlay coordinates, including proportional scale and center offset.
|
||||
const headTop = (leader.bottom - origin.top) / scale;
|
||||
representativeHead.setAttribute('transform', `translate(${(axis - 5).toFixed(4)} ${headTop.toFixed(4)})`);
|
||||
representative.setAttribute('d', `M${axis.toFixed(4)} ${(headTop + 7 + 17).toFixed(4)}V286`);
|
||||
}
|
||||
const x = (bounds.left + bounds.width / 2 - origin.left) / scale;
|
||||
const y = (bounds.top - origin.top) / scale + 2;
|
||||
business.setAttribute('d', `M834 204C650 204 250 235 ${x.toFixed(2)} ${y.toFixed(2)}`);
|
||||
|
||||
Reference in new issue
Block a user