feat(org-chart): add core cards and fixed-layout integration
No files matched your search
@@ -0,0 +1,95 @@
|
||||
# @hm-design/org-chart-react v0.1
|
||||
|
||||
React 19 lifecycle adapter for `@hm-design/org-chart@0.1.0`. The wrapper renders one empty host container and delegates all organization DOM, engine work, validation, summaries, interactions and Shadow DOM styles to the existing Vanilla Core. It has no navigation, page layout, fetch client or separate node design.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { useMemo } from 'react';
|
||||
import { JsonOrgChartDataSource } from '@hm-design/org-chart';
|
||||
import { OrgChart } from '@hm-design/org-chart-react';
|
||||
import type { OrgChartDataset, OrgChartState } from '@hm-design/org-chart-react';
|
||||
import engineCssUrl from '@hm-design/org-chart/engine.css?url';
|
||||
import coreCssUrl from '@hm-design/org-chart/style.css?url';
|
||||
// Core P1 supplies standalone OrgChart design; no HM Theme/font import needed.
|
||||
|
||||
const stylesheetUrls = { engine: engineCssUrl, core: coreCssUrl };
|
||||
|
||||
export function OrganizationContent({ data }: { data: OrgChartDataset }) {
|
||||
const dataSource = useMemo(() => new JsonOrgChartDataSource(data), [data]);
|
||||
const onStateChange = (state: OrgChartState) => {
|
||||
// Data/source/validation/style failures are state.status === 'error'.
|
||||
// A ready state contains the Core's shared summary and dataVersion.
|
||||
void state;
|
||||
};
|
||||
return (
|
||||
<OrgChart
|
||||
dataSource={dataSource}
|
||||
stylesheetUrls={stylesheetUrls}
|
||||
onStateChange={onStateChange}
|
||||
onLifecycleError={error => { console.error('OrgChart lifecycle failed', error); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The `?url` imports above are Vite asset imports, not a universal browser feature. Other hosts resolve the two Core public CSS entries to actual deployment URLs. Importing CSS only into the document does not style the Core's Shadow DOM. Engine CSS is linked first, then standalone Core CSS. P1 owns its colors, typography and geometry without depending on HM Theme. Set public `--hm-orgchart-*` properties on the wrapper container to override only this instance. No HM common variables are redefined. The wrapper does not depend on `@hm-design/ui`; P3 adds lookup Props without replacing the Core renderer. For independent Tree/Summary integration, use the [Core factories](../../docs/org-chart/standalone-components-p1.md) in a ref/effect without copying their UI.
|
||||
|
||||
## Public API
|
||||
|
||||
Named runtime export: `OrgChart`. Type exports: `OrgChartProps` and the Core's public types, reexported as types only. Runtime helpers such as `JsonOrgChartDataSource` remain available from `@hm-design/org-chart`.
|
||||
|
||||
`OrgChartProps` reuses `Omit<OrgChartOptions, 'container' | 'onStateChange'>`:
|
||||
|
||||
- Required `dataSource`: the same Core `OrgChartDataSource` interface, `load(options?: { signal?: AbortSignal }): Promise<unknown>`.
|
||||
- Required `stylesheetUrls`: `{ engine: string; core: string }`, the exact Core asset contract.
|
||||
- Optional `displayMode`: `'interactive'` (default) or `'readOnly'`, passed to the Core composition. Changing it recreates the Core handle.
|
||||
- Optional `organizationLinks`: existing organization-ID-to-URL map, applied only to original 1Depth titles. Missing/blank values retain plain titles. Core validates HTTP(S)/relative URLs and rejects invalid URLs, other schemes and credentials. Treat the map as immutable and memoize it; a new map recreates the handle.
|
||||
- Optional `onEmployeeClick(employee, context)`: host-owned information UI, replacing the Core's built-in popup. `context` contains the existing `organization`, `affiliation`, focusable `trigger` card and `originalEvent`. It works in both modes and receives untouched Employee data.
|
||||
- Optional `aggregationPolicy`: the Core's `ApprovedAggregationPolicy`.
|
||||
- Optional `className` and `style`: applied only to React's outer container.
|
||||
- Optional `onStateChange(state: OrgChartState)`: latest committed callback receives actual Core state changes.
|
||||
- Optional `onLifecycleError(error: unknown)`: reports unexpected Core creation or rejected mount/reload calls and unexpected cleanup errors. Controlled data, source, validation, rendering and stylesheet failures use the Core's `error` state instead.
|
||||
|
||||
The container is owned by React; its dedicated children are owned by Core. There are no React children, imperative handle, title, employee or engine manipulation Props. Titles, descriptions, presentation groups, organization order and metadata use the common data contract. An omitted lifecycle error callback leaves unexpected operation errors handled but does not produce a second error UI or console log. Consumer state callback errors follow the Core's isolation behavior; lifecycle error callback exceptions are also contained.
|
||||
|
||||
Without `onEmployeeClick`, all employee cards open the same Core read-only information dialog on click, Enter or Space. It uses only existing employee/organization/affiliation fields and omits missing values. readOnly names match the displayed `홍 길 동` card while source data remains unchanged. The native modal sits outside the scaled Stage and supports Close, ESC, focus trapping and return to the card. Group-link events remain separate; a visible name tooltip closes before popup activation. A custom callback owns its popup's accessibility and cleanup.
|
||||
|
||||
```tsx
|
||||
<OrgChart
|
||||
dataSource={dataSource}
|
||||
stylesheetUrls={stylesheetUrls}
|
||||
displayMode="readOnly"
|
||||
organizationLinks={configuredOrganizationLinks}
|
||||
onEmployeeClick={(employee, context) => openEmployeeInformation(employee, context)}
|
||||
/>
|
||||
```
|
||||
|
||||
Both map and popup function above are supplied by the host; no real destination URL or employee information is embedded in the package. Omit the callback for the default popup.
|
||||
|
||||
## Lifecycle and updates
|
||||
|
||||
Mount creates one new Core handle, subscribes to its state and calls `mount(container)`. Mount already loads the source; the wrapper does not perform an additional initial reload. The wrapper does not synthesize `idle`, `destroyed` or `empty` callbacks. Current Core emits `loading`, then `ready`/`error`; employees `[]` is ready, organizations `[]` is error. An `empty` type member does not establish a supported Empty State.
|
||||
|
||||
A new `dataSource` object triggers the existing handle's `reload(nextSource)`. This starts Core's latest revision and aborts or ignores older responses. Updating fields inside the same source object is not a reload signal. `JsonOrgChartDataSource` captures inline input at its construction; create a new source for a new immutable snapshot. Memoize sources rather than constructing one on every render.
|
||||
|
||||
Changing callback identity updates the latest committed callback without reload or recreation. Adding/removing `onEmployeeClick` switches built-in/custom popup ownership and recreates the handle. Abandoned concurrent renders do not update callbacks. Policy or `organizationLinks` identity changes, displayMode changes, or either stylesheet URL string value changes dispose the previous handle and create a new one because the Core offers no configuration setter. Replacing the stylesheet URL object with the same two strings does not recreate. Treat policy and link-map objects as immutable and memoize them; changing an object internally is not a configuration update signal.
|
||||
|
||||
Cleanup invalidates the wrapper generation, unsubscribes, calls Core's terminal `destroy()` and clears its handle reference. React Strict Mode's setup → cleanup → setup uses a fresh handle. Old generations cannot deliver state/lifecycle callbacks to the new instance; superseded operation rejection handlers are also ignored. Cleanup runs before replacement, leaving at most one active Core per wrapper container. The wrapper does not repeat engine disposal or DOM removal itself.
|
||||
|
||||
Core regeneration resets expansion/scroll state according to the existing Core contract. Source errors can recover by replacing the source; Core validation/summary/node behavior remains identical to Vanilla. Invalid runtime Props that prevent Core construction are lifecycle usage errors: fix them and remount the component (for example by changing its React key). No rendered fallback silently replaces an invalid configuration.
|
||||
|
||||
Committed callback publication, Core ownership and source updates use layout effects in that order. Cleanup/reload runs synchronously with React commit, so a pending old response cannot notify in a gap before passive cleanup. Server rendering produces only the outer container; SSR/hydration remains outside the verified scope.
|
||||
|
||||
## Package boundary and verification limits
|
||||
|
||||
The build emits ESM `dist/index.js` plus TypeScript declarations, preserves the `"use client"` boundary and leaves React (including `react/jsx-runtime`) and `@hm-design/org-chart` external. React is a `^19.0.0` peer, matching the repository's existing React package convention; Core `0.1.0` is a runtime dependency. React DOM is supplied by the consuming app and is not imported by this package. Core must be built before wrapper typecheck/build in this workspace because dependency resolution uses its public declarations.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
npm run typecheck --workspace @hm-design/org-chart-react
|
||||
npm run build --workspace @hm-design/org-chart-react
|
||||
```
|
||||
|
||||
Storybook is one React host, not proof of every consumer deployment. This adapter does not add pan/zoom, advanced mobile reflow, additional relation overlays or reserved Core presentation sections. SSR/hydration, full screen reader behavior, complete heap/GC recovery, Theme-less rendering, Workdesk reference visual approval and production consumers require their own evidence. Public ESM import without a DOM does not establish SSR/hydration support. Consult the final integration report for the actual typecheck/build/browser/consumer results.
|
||||
@@ -0,0 +1,41 @@
|
||||
import { build } from 'vite';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = fileURLToPath(new URL('./', import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const manifest = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
||||
const externals = Object.keys({ ...manifest.dependencies, ...manifest.peerDependencies });
|
||||
|
||||
await build({
|
||||
configFile: false,
|
||||
root,
|
||||
publicDir: false,
|
||||
build: {
|
||||
target: 'es2022',
|
||||
outDir: 'dist',
|
||||
minify: false,
|
||||
lib: { entry: path.join(root, 'src/index.ts'), formats: ['es'], fileName: () => 'index.js' },
|
||||
rolldownOptions: {
|
||||
external: id => externals.some(dependency => id === dependency || id.startsWith(`${dependency}/`)),
|
||||
onwarn(warning, warn) {
|
||||
// Restore the client boundary on the final public entry below.
|
||||
if (warning.code === 'MODULE_LEVEL_DIRECTIVE') return;
|
||||
warn(warning);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const typescriptManifest = require.resolve('typescript/package.json');
|
||||
const typescript = JSON.parse(await readFile(typescriptManifest, 'utf8'));
|
||||
execFileSync(process.execPath, [
|
||||
path.resolve(path.dirname(typescriptManifest), typescript.bin.tsc),
|
||||
'-p', path.join(root, 'tsconfig.build.json'),
|
||||
], { stdio: 'inherit' });
|
||||
|
||||
const entry = path.join(root, 'dist/index.js');
|
||||
await writeFile(entry, `"use client";\n${await readFile(entry, 'utf8')}`);
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@hm-design/org-chart-react",
|
||||
"version": "0.1.0",
|
||||
"description": "React lifecycle adapter for the framework-independent HM OrgChart Core.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist", "README.md"],
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"build": "node build.mjs",
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": { "@hm-design/org-chart": "0.1.0" },
|
||||
"peerDependencies": { "react": "^19.0.0" }
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
import { createOrgChart } from '@hm-design/org-chart';
|
||||
import type { OrgChartDataSource, OrgChartHandle } from '@hm-design/org-chart';
|
||||
import type { OrgChartProps } from './types.js';
|
||||
|
||||
interface Instance {
|
||||
readonly handle: OrgChartHandle;
|
||||
readonly generation: number;
|
||||
source: OrgChartDataSource;
|
||||
operation: number;
|
||||
}
|
||||
|
||||
/** A React host for the exact same Core used by Vanilla HTML. */
|
||||
export function OrgChart({
|
||||
dataSource,
|
||||
aggregationPolicy,
|
||||
stylesheetUrls,
|
||||
displayMode,
|
||||
organizationLinks,
|
||||
onEmployeeClick,
|
||||
className,
|
||||
style,
|
||||
onStateChange,
|
||||
onLifecycleError,
|
||||
}: OrgChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const instanceRef = useRef<Instance | null>(null);
|
||||
const generationRef = useRef(0);
|
||||
const committedRef = useRef({ dataSource, onStateChange, onLifecycleError, onEmployeeClick });
|
||||
|
||||
// Publish committed values before Core effects run, without letting callback
|
||||
// identity changes or abandoned concurrent renders alter Core ownership.
|
||||
useLayoutEffect(() => {
|
||||
committedRef.current = { dataSource, onStateChange, onLifecycleError, onEmployeeClick };
|
||||
}, [dataSource, onStateChange, onLifecycleError, onEmployeeClick]);
|
||||
|
||||
const engineStylesheetUrl = stylesheetUrls.engine;
|
||||
const coreStylesheetUrl = stylesheetUrls.core;
|
||||
const hasEmployeeCallback = typeof onEmployeeClick === 'function';
|
||||
|
||||
// Ownership changes are synchronous with the commit: an old request cannot
|
||||
// notify between a committed unmount/source change and a passive cleanup.
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const generation = ++generationRef.current;
|
||||
let handle: OrgChartHandle | undefined;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
const reportError = (error: unknown) => {
|
||||
if (generationRef.current !== generation) return;
|
||||
try { committedRef.current.onLifecycleError?.(error); }
|
||||
catch { /* Consumer errors must not leave rejected lifecycle promises. */ }
|
||||
};
|
||||
|
||||
try {
|
||||
const source = committedRef.current.dataSource;
|
||||
handle = createOrgChart({
|
||||
dataSource: source,
|
||||
aggregationPolicy,
|
||||
stylesheetUrls: { engine: engineStylesheetUrl, core: coreStylesheetUrl },
|
||||
displayMode,
|
||||
organizationLinks,
|
||||
onEmployeeClick: hasEmployeeCallback ? (employee, context) => {
|
||||
if (generationRef.current === generation) committedRef.current.onEmployeeClick?.(employee, context);
|
||||
} : undefined,
|
||||
});
|
||||
const instance: Instance = { handle, source, generation, operation: 1 };
|
||||
instanceRef.current = instance;
|
||||
unsubscribe = handle.subscribe(state => {
|
||||
if (instanceRef.current === instance && generationRef.current === generation) {
|
||||
committedRef.current.onStateChange?.(state);
|
||||
}
|
||||
});
|
||||
// Core mount performs the first load. The source effect below compares
|
||||
// identity against this record and does not load the initial source again.
|
||||
const operation = instance.operation;
|
||||
void handle.mount(container).catch(error => {
|
||||
if (instanceRef.current === instance && instance.operation === operation) reportError(error);
|
||||
});
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Invalidate callbacks and promise continuations before terminal disposal.
|
||||
if (generationRef.current === generation) ++generationRef.current;
|
||||
if (instanceRef.current?.generation === generation) instanceRef.current = null;
|
||||
try { unsubscribe?.(); }
|
||||
catch (error) {
|
||||
try { committedRef.current.onLifecycleError?.(error); }
|
||||
catch { /* A consumer callback cannot prevent terminal Core disposal. */ }
|
||||
}
|
||||
finally {
|
||||
try { handle?.destroy(); }
|
||||
catch (error) {
|
||||
try { committedRef.current.onLifecycleError?.(error); }
|
||||
catch { /* Cleanup still completes when the consumer callback throws. */ }
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [aggregationPolicy, engineStylesheetUrl, coreStylesheetUrl, displayMode, organizationLinks, hasEmployeeCallback]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const instance = instanceRef.current;
|
||||
if (!instance || instance.source === dataSource) return;
|
||||
instance.source = dataSource;
|
||||
const operation = ++instance.operation;
|
||||
// Core reload synchronously starts a new revision, aborting/ignoring earlier
|
||||
// source requests even when their Promise or engine import is still pending.
|
||||
void instance.handle.reload(dataSource).catch(error => {
|
||||
if (instanceRef.current !== instance || generationRef.current !== instance.generation || instance.operation !== operation) return;
|
||||
try { committedRef.current.onLifecycleError?.(error); }
|
||||
catch { /* Consumer errors must not become unhandled Promise rejections. */ }
|
||||
});
|
||||
}, [dataSource]);
|
||||
|
||||
return <div ref={containerRef} className={className} style={style} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"use client";
|
||||
|
||||
export { OrgChart } from './OrgChart.js';
|
||||
export type { OrgChartProps } from './types.js';
|
||||
export type * from '@hm-design/org-chart';
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { OrgChartOptions, OrgChartState } from '@hm-design/org-chart';
|
||||
|
||||
/** The Core owns data, rendering, organization links and employee information
|
||||
* interactions; React owns its dedicated container. */
|
||||
export interface OrgChartProps extends Omit<OrgChartOptions, 'container' | 'onStateChange'> {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
/** Latest committed callback. Controlled data/style failures arrive as error states. */
|
||||
onStateChange?: (state: OrgChartState) => void;
|
||||
/** Unexpected create/mount/reload/cleanup errors, rather than controlled error states. */
|
||||
onLifecycleError?: (error: unknown) => void;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": false,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noEmit": true,
|
||||
"types": ["react"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
# @hm-design/org-chart v0.1
|
||||
|
||||
Framework-independent TypeScript/DOM Core backed by **dabeng orgchart 6.0.0**. No React runtime, wrapper, Storybook, navigation or API client. This version renders organizational hierarchy and employee/leader cards; additional relations are validated but are not drawn.
|
||||
|
||||
P1 composes a separate top leader, presentation-only hub, hierarchy, position/affiliation summaries and dispatched/onLeave groups. The same `presentation.groups` fields hide each group independently. The hub does not create a fictitious organization or change `parentId`.
|
||||
|
||||
```js
|
||||
import { createOrgChart, JsonOrgChartDataSource } from '@hm-design/org-chart';
|
||||
|
||||
const chart = createOrgChart({
|
||||
container: document.getElementById('chart'),
|
||||
dataSource: new JsonOrgChartDataSource(configuredJsonUrl),
|
||||
// Host/bundler resolves the two exported CSS assets to absolute URLs.
|
||||
stylesheetUrls: { engine: engineCssUrl, core: coreCssUrl },
|
||||
onStateChange(state) { /* idle/loading/ready/error/destroyed */ },
|
||||
});
|
||||
await chart.mount();
|
||||
await chart.update(nextDataset);
|
||||
await chart.reload();
|
||||
chart.destroy();
|
||||
```
|
||||
|
||||
`displayMode` (optional, default `'interactive'`) selects the full composition presentation. `'readOnly'` is the view-only Workdesk layout: the hierarchy is fully expanded with no collapse buttons or diagnostic text (kind, ID, raw counts, vacancy, description), summaries use compact presenters of the same Summary (organization-by-position table, untitled affiliation bar and status groups), and a 1920 × 914.5 design canvas scales proportionally to the available container width and height. Standalone components ignore it.
|
||||
|
||||
## Organization links and employee information (P3)
|
||||
|
||||
### P3.2 follow-up: representative badges
|
||||
|
||||
Representative cards never render job-title, role or status badges, empty attribute wrappers or a badge row, including standalone specimens. They display `대표이사` / `홍 길 동`, retaining the approved 146×74 asset and existing typography, with no gap between position and name. The fixture's raw representative position (`경영진`), employee data and dialog fields remain unchanged. Other 1Depth/2Depth badge policies are preserved. This display policy takes precedence over the historical P3.2/P3.1 notes below.
|
||||
|
||||
### P3.2 compact typography and card logos
|
||||
|
||||
Large department cards use 24px names / 18px positions; compact cards use 18px names / 15px positions. Representative typography and the existing weights, line heights and colors are retained. Read-only dispatched/onLeave cards share the compact content layout (108×52 card, 24×44 photo slot), with their original gray surface and text colors. Status headers share the standard 2Depth 20px/700/19.7px typography; existing department-specific exceptions remain. Position and badge whitespace is removed only for display.
|
||||
|
||||
Employee-card logo contain boxes scale exactly 1.5×: small 12→18px; representative/large/status 14→21px. Original assets and footer logos stay unchanged. See [P3.2 report](../../docs/org-chart/implementation-report-p3.2.md) for before/after browser measurements and preservation evidence. Historical P3.1 geometry below describes that earlier stage.
|
||||
|
||||
### P3.1 display policy and local font loading
|
||||
|
||||
The OrgChart-scoped default family is **Noto Sans KR**; existing `--hm-orgchart-font-family` overrides remain available. The host must register its font-face once: HM Storybook already loads the existing HM Theme face. The HTML P1 host loads the unchanged `public/fonts/NotoSansKR-variable.ttf` copied to `assets/fonts/`; the React P1 host references that same source through its bundler. Global HM Theme files are unchanged. The Korean Core shell declares `lang="ko"` for consistent glyph shaping across host languages.
|
||||
|
||||
Read-only Full Composition suppresses only the `경영진` **job-title badge**. Positions (including a literal `경영진` position), other raw badges, employee records, popup fields and statistics are retained. Position whitespace is removed only in the card display. Other renderer modes keep their badge policy. All card types share one rectangular neutral SVG for a missing or failed profile image; an available profile image takes precedence. Approved cards, photo-slot dimensions and P3 status-card geometry are unchanged. See [P3.1 report](../../docs/org-chart/implementation-report-p3.1.md) for focused browser evidence and limits.
|
||||
|
||||
Optional `organizationLinks: Readonly<Record<string, string | undefined>>` maps existing organization IDs to host-configured URLs. Only original hierarchy depth 1 titles become native links. Missing, undefined or blank URLs keep ordinary titles; no destination is generated. HTTP(S) and relative URLs are accepted; invalid URLs, other schemes and credentials reject construction with `TypeError`. Group links retain native keyboard navigation and a visible focus outline, separately from employee activation.
|
||||
|
||||
All employee cards, including the representative and dispatched/onLeave cards, support click, Enter and Space in both modes. By default they open a read-only native information dialog in the unscaled shell, outside the Stage. It shows only existing name, position, organization, job title, affiliation and work-status values; unavailable optional values are omitted. The readOnly fictitious display name matches the activated card (`홍 길 동`); underlying employee data stays unchanged. Close button, ESC, modal focus trapping and card focus restoration are provided. A name-overflow tooltip closes when information UI opens.
|
||||
|
||||
To use a host-owned popup, provide the additive callback below. Supplying it replaces the built-in dialog; the host then owns popup accessibility and cleanup. The callback receives the unchanged Employee and resolved existing organization/affiliation, the focusable trigger card and original event.
|
||||
|
||||
```js
|
||||
const chart = createOrgChart({
|
||||
...configuredBaseOptions,
|
||||
organizationLinks: configuredOrganizationLinks,
|
||||
onEmployeeClick(employee, { organization, affiliation, trigger, originalEvent }) {
|
||||
openEmployeeInformation({ employee, organization, affiliation, trigger, originalEvent });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Omit `onEmployeeClick` to use the default popup. Vanilla options are captured at construction; use a new handle to change the URL map or callback. There is no public Core `setOptions` method.
|
||||
|
||||
`mount(container)` also accepts the container at mount time. Constructing a handle and importing the ESM facade require no DOM. Mount requires a connected HTMLElement. One handle owns one container, preserving host siblings. Duplicate mount and update/reload before mount or after destroy reject with a usage error. Data/Source failures become structured `error` states and resolve the operation. Destroy is synchronous, idempotent and terminal; use a new handle to mount again. `subscribe(listener)` returns an unsubscribe function; `getState()` returns the immutable current state. Consumer callback errors do not break the Core.
|
||||
|
||||
`update` captures its input immediately and preserves the current Source. `reload(source?)` optionally replaces it. The last started operation wins; earlier requests are aborted, and stale responses cannot commit. Call completion means the operation was applied, superseded, or produced a controlled error, rather than that every image/font finished loading. Regeneration resets expand/scroll state; removed keyboard focus moves to the scroll region.
|
||||
|
||||
## Data contract
|
||||
|
||||
Public `OrgChartDataset`, `Organization`, `Employee`, `Affiliation`, `Metadata`, `PresentationConfig` and Source types are exported with declarations. Preserve documented names: `displayOrder`, `jobTitle`, `profileImage`, `workStatus`. The supported schema version remains **`0.1-draft`**; no silent migration is performed. The latest implementation request requires top-level `schemaVersion`; `metadata.schemaVersion` is an optional compatibility mirror and must match when supplied. `metadata.updatedAt/updateNote` and dataset `relations/presentation` are optional extensions. The original seven design documents are historical drafts and are preserved.
|
||||
|
||||
Organization and employee IDs have independent namespaces. All references are checked; duplicate IDs within a collection, unknown fields, missing values, type errors, invalid dates/versions/statuses, parent cycles, unsafe markup/URLs and empty organizations produce `{valid:false, issues:[{code,path,message}]}` from `validateOrgChartDataset`. Zero employees is valid. Leaders outside their assigned organization require a future approved rule and are rejected with `LEADER_APPROVAL_REQUIRED`. Multiple roots remain separate trees, without a fabricated parent. Order is presentation order, then displayOrder, then code-unit ID order.
|
||||
|
||||
`normalizeOrgChartDataset` and `calculateSummary` accept validated datasets. `calculateSummary` exposes `raw` counts for unique original employee IDs: total, direct/inclusive per organization, position, affiliation and status. These literal source counts are **not an approved business headcount**. No policy gives `aggregation.status='unavailable'`/`policyRequired`. A caller may provide an explicitly approved policy (`policyVersion`, `includedStatuses`, `activeStatuses`, `includeLeaders:true`, `membershipMode:'primaryOnly'`). Unsupported leader exclusion/multiple-membership rules or invalid policies return `unsupportedPolicy`. This release does not infer business meaning for dispatched/onLeave status.
|
||||
|
||||
`JsonOrgChartDataSource` accepts an inline dataset or an HTTP(S)/relative URL, optionally `{fetch}`. Inline input is captured at construction; every load returns a fresh immutable snapshot. External HTTP/JSON errors are sanitized and distinguished by `OrgChartSourceError.code`. Source output is unknown until Core validation. Future API sources implement `load(options?:{signal?:AbortSignal}):Promise<unknown>` and do not change Core APIs. URL paths are injected by the host, not embedded in Core. External JSON Fetch is an HTTP example; it is not supported as a file:// transport.
|
||||
|
||||
## Theme and CSS ownership
|
||||
|
||||
P1 **ships an independent OrgChart design**. It requires no HM Theme, React/UI peer or font download. Resolve the two exported CSS assets to URLs; the Core loads them only into its dedicated open Shadow DOM. `style.css` includes its own scoped defaults and contains no private CSS imports. Summary-only components require only this CSS. `/browser` is a classic IIFE exposing `window.HMOrgChart`; `dist/index.js` is bundled ESM with no bare package imports.
|
||||
|
||||
All colors, typography, spacing, borders, radius and gradients belong to `src/styles/design-tokens.css` and `src/core/style-presets.ts`. Public `--hm-orgchart-*` inputs inherit from the caller's container; internal `--hm-orgchart-resolved-*` aliases resolve defaults without overriding inherited inputs. HM Theme variables do not affect this design. Workdesk organization header gradients and white heading text are measured; neutral/card styling is a documented design adaptation, not an official Workdesk value. Existing v0.1 demos and historical validation records are preserved. P1 theme-free examples are `dist/p1.html` (HTTP/ESM) and `dist/p1-classic.html` (direct file/inline JSON). See [standalone component integration](../../docs/org-chart/standalone-components-p1.md).
|
||||
|
||||
## Engine disposal boundary
|
||||
|
||||
Upstream provides no official destroy API. The facade clears its DOM, aborts requests and generation event signals, clears callback/data/engine references and disconnects the init observer through a narrow managed subclass hook. pan/zoom/minimap/drag/export are disabled to bound resource ownership; hierarchy expansion uses native buttons and engine show/hide methods. Transition listeners are removed with their discarded DOM. Browser verification records repeated lifecycle DOM, global event listener and active observer stability. It does **not** establish complete heap/GC recovery. The hook depends on pinned 6.0.0 internals and must be re-audited before an engine upgrade.
|
||||
|
||||
## Build and examples
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
npm run typecheck:org-chart
|
||||
npm run test:org-chart
|
||||
npm run build:org-chart-example
|
||||
npm run dev:org-chart-example
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:4178/index.html` for bundled ESM + external JSON. Open `examples/org-chart/dist/classic.html` directly for classic bundle + inline fictitious JSON. Both use package build outputs, never Spike code. Example controls demonstrate reload, employee movement/addition, destroy and recreation. Build artifacts are Git-ignored; source fixtures are checked in.
|
||||
|
||||
`npm run test:org-chart-browser` expects Playwright supplied by the verification environment. If not installed locally, set `ORGCHART_PLAYWRIGHT_MODULE` to its absolute `index.mjs`/`index.js` and optionally `ORGCHART_CHROME` to a Chrome executable. Production has no browser-test dependency. Evidence is written under `test-results/org-chart-browser/`.
|
||||
|
||||
The React wrapper owns only ref/effect lifecycle, source/props updates and cleanup, including forwarding the additive P3 lookup options; HTML and React render the same Core. Standalone `createOrgChartTree`, `createPositionSummary`, `createAffiliationSummary` and `createStatusSummary` factories are exported at the package root and return the existing `OrgChartHandle`. The three public OrgChart Stories are Logo · Personnel Summary, Department Colors and Full Composition. Original verification Stories and independent component Stories remain available under the excluded `internal` tag.
|
||||
@@ -0,0 +1,40 @@
|
||||
import { build } from 'vite';
|
||||
import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = fileURLToPath(new URL('./', import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const engineRoot = path.dirname(require.resolve('orgchart/package.json'));
|
||||
const engine = JSON.parse(await readFile(path.join(engineRoot, 'package.json'), 'utf8'));
|
||||
if (engine.version !== '6.0.0') throw new Error('Expected pinned orgchart 6.0.0');
|
||||
// Keep upstream UMD evaluation inside the deferred import; bundling removes bare imports.
|
||||
for (const format of ['es', 'iife']) {
|
||||
await build({
|
||||
configFile: false, root, publicDir: false,
|
||||
build: {
|
||||
target: 'es2022', outDir: 'dist', emptyOutDir: false, minify: false,
|
||||
lib: {
|
||||
entry: path.join(root, 'src/index.ts'), name: 'HMOrgChart', formats: [format],
|
||||
fileName: () => format === 'es' ? 'index.js' : 'orgchart.browser.js',
|
||||
},
|
||||
rolldownOptions: { output: { codeSplitting: false } },
|
||||
},
|
||||
});
|
||||
}
|
||||
const typescriptManifest = require.resolve('typescript/package.json');
|
||||
const typescript = JSON.parse(await readFile(typescriptManifest, 'utf8'));
|
||||
execFileSync(process.execPath, [path.resolve(path.dirname(typescriptManifest), typescript.bin.tsc), '-p', path.join(root, 'tsconfig.build.json')], { stdio: 'inherit' });
|
||||
// Ship one self-contained public CSS asset. ?url and file:// consumers cannot
|
||||
// rely on source-relative imports or a bundler to discover private style files.
|
||||
const coreStyles = await readFile(path.join(root, 'src/styles/org-chart.css'), 'utf8');
|
||||
const inlinedStyles = await Promise.all(['design-tokens.css', 'employee-card.css', 'summary-sections.css', 'org-chart-content.css', 'org-chart-readonly.css', 'org-chart-p3-summary.css', 'employee-info-dialog.css'].map(async file => [file, await readFile(path.join(root, 'src/styles', file), 'utf8')]));
|
||||
let publicStyles = coreStyles;
|
||||
for (const [file, css] of inlinedStyles) publicStyles = publicStyles.replace(`@import './${file}';`, css);
|
||||
await writeFile(path.join(root, 'dist/style.css'), publicStyles);
|
||||
await cp(path.join(engineRoot, 'dist/css/orgchart.css'), path.join(root, 'dist/engine.css'));
|
||||
await mkdir(path.join(root, 'dist/licenses'), { recursive: true });
|
||||
await cp(path.join(engineRoot, 'LICENSE'), path.join(root, 'dist/licenses/orgchart-MIT.txt'));
|
||||
await writeFile(path.join(root, 'dist/licenses/README.txt'), 'Bundled engine: dabeng orgchart 6.0.0 (MIT). Upstream UMD references html2canvas/jspdf only for export, which this Core disables. Those export libraries are not bundled or supported. json-digger is not imported by the shipped UMD. The npm dependency retains the upstream dependency manifest.\n');
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@hm-design/org-chart",
|
||||
"version": "0.1.0",
|
||||
"description": "Framework-independent HM OrgChart core, data contract and JSON sources.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" },
|
||||
"./style.css": "./dist/style.css",
|
||||
"./engine.css": "./dist/engine.css",
|
||||
"./browser": "./dist/orgchart.browser.js"
|
||||
},
|
||||
"files": ["dist", "README.md"],
|
||||
"sideEffects": ["**/*.css"],
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"build": "node build.mjs",
|
||||
"test": "node test.mjs",
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": { "orgchart": "6.0.0" }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# 관계사 로고 출처
|
||||
|
||||
사용자가 공식 접근 가능한 Figma Asset 재사용을 명시적으로 요청하여, 2026-10-12에 아래 Figma 파일의 관계사별 집계 Footer에서 가져온 PNG를 보존했습니다. 원본 로고를 그대로 사용하며 OrgChart 공통 카드·Footer는 같은 로컬 Asset을 참조합니다.
|
||||
|
||||
- Figma file key: `Plr6RRLi5XnLIn60x9aG1s`
|
||||
- Footer 원본: [관계사별 집계 Footer, node 22:179](https://www.figma.com/design/Plr6RRLi5XnLIn60x9aG1s/?node-id=22-179)
|
||||
- 확인일: `2026-10-12` (KST)
|
||||
- 사용 범위: OrgChart 내부 관계사 표시. 실제 인물 프로필 사진은 다운로드하지 않았습니다.
|
||||
- 배포 경로: 로컬 PNG를 `?inline` import하여 번들에 포함합니다. Figma 임시 원격 다운로드 URL은 배포하지 않습니다.
|
||||
- 로고 필드·Employee JSON 데이터 계약은 추가하지 않습니다. 기존 관계사 ID와 내부 Asset 매핑을 사용하며, 매핑이 없는 ID나 이미지 오류는 관계사명 모노그램으로 대체합니다.
|
||||
- 접근 가능한 원본 Footer의 시각적 확인 결과를 근거로 합니다. 통합 카드·Footer의 최종 브라우저 검증은 별도 통합 검증 기록을 따릅니다.
|
||||
|
||||
원본 Node 및 저장 파일 SHA-256 (모두 0바이트 초과 확인):
|
||||
|
||||
- `hanmac.png` — node `22:181`; 11,828 bytes; `db154af30d5ef875d097006c92dc5bdd70962ed2538cfdd385c87b39cecb3cfa`
|
||||
- `saman.png` — node `22:187`; 20,584 bytes; `5167420984ef7b0603f8203f35a99ffcfcbc7981186beb785ce38a1665fda60c`
|
||||
- `jangheon.png` — node `22:193`; 56,063 bytes; `6b4047ae6ecc451121470885d1f382b3f499c838968a02120d6d3dd643503e63`
|
||||
- `jangheon-industry.png` — node `22:199`; 16,683 bytes; `6d12832bfe067414889d6db2ec4ee4910f5d2e61dbd86b9edd1516ca2a51733d`
|
||||
- `ptc.png` — node `22:205`; 7,613 bytes; `12c22132e83be1f4feb23e5f761b73a85366ae399387cd6b40782d501dd7cadb`
|
||||
- `halla.png` — node `22:211`; 9,606 bytes; `f92408ae7f129e3427c1456de0d9da855bbd652e72b97728e3cdc6b11fa6d12b`
|
||||
- `baron.png` — node `22:217`; 13,175 bytes; `089014f781d6b741397388eb87c70b9817e848eb5960fc5b7933033b724ca351`
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,13 @@
|
||||
# OrgChart 1단계 카드 자산
|
||||
|
||||
- `대표자.svg`: 사용자가 본문으로 전달한 SVG. 최상단 독립 `topLeader` 전용, 146×74. 부서 자산 매핑에 넣지 않는다.
|
||||
- `사업관리팀.svg`, `사업관리팀-sm.svg`: 사용자 PNG에서 색상·알파·내측 그라데이션 방향을 추출한 벡터. 조직장 146×68 / 일반 직원 108×52. PNG를 SVG에 삽입하지 않는다.
|
||||
- 경영기획팀 / 디자인기획팀 / 솔루션통합팀 / 인재성장팀 / 통합시스템 디비전의 큰·작은 SVG 10개: `D:\디자인시스템\카드 이미지`에서 원본 바이트를 그대로 복사했다. SHA-256 일치는 `test-results/org-chart-card-design/browser.json`에서 확인한다.
|
||||
|
||||
부서 매핑은 `src/core/department-card-assets.ts`, 대표자 전용 매핑은 `src/core/representative-card-asset.ts`에 있다. `solution-sales`에 솔루션통합팀 자산을 쓰는 것은 이번 작업에서 사용자가 명시 승인했다. 하위 조직은 실제 `parentId`로 상위 부서 자산을 찾으며 데이터 JSON을 수정하지 않는다.
|
||||
|
||||
사업관리 PNG 원본은 확장자가 없는 `D:\디자인시스템\카드 이미지\사업관리`(PNG, 408×191)다. 추출 색은 외곽 `#DEE6F0 → #A8BBD1 → #577BA5`(마지막 opacity 0.6), 내측 `#FFFFFF → #E0E6F4`다. 경영기획과 색상 끝점은 같지만 사업관리 PNG의 투명도와 내측 그라데이션 방향을 별도로 유지했다.
|
||||
|
||||
재생성: `examples/org-chart/create-business-card-assets.py <원본 PNG 경로>`. Pillow/numpy가 필요하다. 추출 근거는 `business-management-colors.json`, Chrome 실제 래스터 비교는 `business-management-comparison.json`에 보존한다. 신규 벡터의 외곽·곡선 path는 같은 크기의 경영기획 SVG를 재사용한다.
|
||||
|
||||
SVG는 `?inline`으로 번들에 포함되고 별도 장식 이미지 레이어로 렌더링된다. 직원 이름·직위·직책·상태·프로필·관계사 로고는 기존 데이터 레이어다. 파견/휴직 보조 영역에는 새 자산을 전달하지 않는다. 고정 카드 크기의 소비자 override는 이 원본 비율 variant에 적용하지 않는다.
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg width="108" height="52" viewBox="0 0 108 52" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 48.494C0 50.4303 1.5697 52 3.50602 52H104.494C106.43 52 108 50.4303 108 48.494V3.50602C108 1.5697 106.43 0 104.494 0H3.50602C1.5697 0 0 1.5697 0 3.50602V48.494Z" fill="url(#paint0_linear_69_2371)"/>
|
||||
<path d="M1.47852 48.1332C1.47852 49.4241 2.52498 50.4706 3.81587 50.4706H104.334C105.541 50.4706 106.52 49.492 106.52 48.2849C106.52 22.4626 85.5865 1.5294 59.7641 1.5294H3.81587C2.52499 1.5294 1.47852 2.57587 1.47852 3.86675V48.1332Z" fill="url(#paint1_linear_69_2371)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_69_2371" x1="92.1155" y1="10.1048" x2="66.3798" y2="53.0473" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DEE6F0"/>
|
||||
<stop offset="0.72" stop-color="#A8BBD1"/>
|
||||
<stop offset="1" stop-color="#577BA5"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_69_2371" x1="57.7525" y1="19.8874" x2="43.9106" y2="50.2297" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="0.97" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="146" height="68" viewBox="0 0 146 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 64.494C0 66.4303 1.5697 68 3.50602 68H142.494C144.43 68 146 66.4303 146 64.494V3.50603C146 1.5697 144.43 0 142.494 0H3.50602C1.5697 0 0 1.5697 0 3.50603V64.494Z" fill="url(#paint0_linear_67_1090)"/>
|
||||
<path d="M2 63.6627C2 64.9535 3.04647 66 4.33735 66H141.663C142.954 66 144 64.9535 144 63.6627V52C144 24.3858 121.614 2 94 2H4.33735C3.04647 2 2 3.04647 2 4.33735V63.6627Z" fill="url(#paint1_linear_67_1090)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_67_1090" x1="124.527" y1="13.2139" x2="91.4091" y2="70.3397" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DEE6F0"/>
|
||||
<stop offset="0.72" stop-color="#A8BBD1"/>
|
||||
<stop offset="1" stop-color="#577BA5"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_67_1090" x1="78.0741" y1="26.0067" x2="60.3685" y2="66.1292" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="0.97" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1013 B |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="146" height="74" viewBox="0 0 146 74" fill="none">
|
||||
<path d="M0 70C0 72.2091 1.79086 74 4 74H142C144.209 74 146 72.2091 146 70V4.00001C146 1.79086 144.209 0 142 0H4C1.79086 0 0 1.79086 0 4V70Z" fill="url(#paint0_linear_22_271)"/>
|
||||
<path d="M2 69.6627C2 70.9535 3.04647 72 4.33735 72H142.013C143.11 72 144 71.1103 144 70.0129C144 32.4504 113.55 2 75.9871 2H4.33735C3.04647 2 2 3.04647 2 4.33735V69.6627Z" fill="url(#paint1_linear_22_271)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_22_271" x1="123.344" y1="9.12442" x2="104.553" y2="74.0869" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#A7B9DF"/>
|
||||
<stop offset="0.6" stop-color="#A7B9DF"/>
|
||||
<stop offset="1" stop-color="#0135A1" stop-opacity="0.5"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_22_271" x1="77.3365" y1="18.8479" x2="77.3365" y2="72" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="108" height="52" viewBox="0 0 108 52" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 48.494C0 50.4303 1.5697 52 3.50602 52H104.494C106.43 52 108 50.4303 108 48.494V3.50602C108 1.5697 106.43 3.73075e-09 104.494 3.73075e-09H3.50602C1.5697 3.73075e-09 0 1.5697 0 3.50602V48.494Z" fill="url(#paint0_linear_69_2374)"/>
|
||||
<path d="M1.48047 48.1332C1.48047 49.4241 2.52694 50.4706 3.81782 50.4706H104.336C105.543 50.4706 106.522 49.492 106.522 48.2849C106.522 22.4626 85.5884 1.5294 59.7661 1.5294H3.81782C2.52694 1.5294 1.48047 2.57587 1.48047 3.86675V48.1332Z" fill="url(#paint1_linear_69_2374)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_69_2374" x1="92.1156" y1="10.1048" x2="66.3799" y2="53.0473" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DDEEEF"/>
|
||||
<stop offset="0.72" stop-color="#A2D1D1"/>
|
||||
<stop offset="1" stop-color="#319D99" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_69_2374" x1="58.7484" y1="16.1449" x2="54.0638" y2="50.215" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="146" height="68" viewBox="0 0 146 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 64.494C0 66.4303 1.5697 68 3.50602 68H142.494C144.43 68 146 66.4303 146 64.494V3.50605C146 1.56973 144.43 3.05176e-05 142.494 3.05176e-05H3.50603C1.5697 3.05176e-05 0 1.56973 0 3.50605V64.494Z" fill="url(#paint0_linear_67_59)"/>
|
||||
<path d="M2 63.6627C2 64.9535 3.04647 66 4.33735 66H141.663C142.954 66 144 64.9535 144 63.6627V52C144 24.3858 121.614 2 94 2H4.33735C3.04647 2 2 3.04647 2 4.33735V63.6627Z" fill="url(#paint1_linear_67_59)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_67_59" x1="124.527" y1="13.214" x2="91.4092" y2="70.3397" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DDEEEF"/>
|
||||
<stop offset="0.72" stop-color="#A2D1D1"/>
|
||||
<stop offset="1" stop-color="#319D99" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_67_59" x1="79.4178" y1="21.1125" x2="73.4847" y2="65.7189" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="108" height="52" viewBox="0 0 108 52" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 48.494C0 50.4303 1.5697 52 3.50602 52H104.494C106.43 52 108 50.4303 108 48.494V3.50602C108 1.5697 106.43 0 104.494 0H3.50602C1.5697 0 0 1.5697 0 3.50602V48.494Z" fill="url(#business_management_outer_69_2371)"/>
|
||||
<path d="M1.47852 48.1332C1.47852 49.4241 2.52498 50.4706 3.81587 50.4706H104.334C105.541 50.4706 106.52 49.492 106.52 48.2849C106.52 22.4626 85.5865 1.5294 59.7641 1.5294H3.81587C2.52499 1.5294 1.47852 2.57587 1.47852 3.86675V48.1332Z" fill="url(#business_management_inner_69_2371)"/>
|
||||
<defs>
|
||||
<linearGradient id="business_management_outer_69_2371" x1="92.1155" y1="10.1048" x2="66.3798" y2="53.0473" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DEE6F0"/>
|
||||
<stop offset="0.72" stop-color="#A8BBD1"/>
|
||||
<stop offset="1" stop-color="#577BA5" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="business_management_inner_69_2371" x1="57.7534" y1="15.6726" x2="53.1643" y2="49.8431" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFFFFF"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="146" height="68" viewBox="0 0 146 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 64.494C0 66.4303 1.5697 68 3.50602 68H142.494C144.43 68 146 66.4303 146 64.494V3.50603C146 1.5697 144.43 0 142.494 0H3.50602C1.5697 0 0 1.5697 0 3.50603V64.494Z" fill="url(#business_management_outer_67_1090)"/>
|
||||
<path d="M2 63.6627C2 64.9535 3.04647 66 4.33735 66H141.663C142.954 66 144 64.9535 144 63.6627V52C144 24.3858 121.614 2 94 2H4.33735C3.04647 2 2 3.04647 2 4.33735V63.6627Z" fill="url(#business_management_inner_67_1090)"/>
|
||||
<defs>
|
||||
<linearGradient id="business_management_outer_67_1090" x1="124.527" y1="13.2139" x2="91.4091" y2="70.3397" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DEE6F0"/>
|
||||
<stop offset="0.72" stop-color="#A8BBD1"/>
|
||||
<stop offset="1" stop-color="#577BA5" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="business_management_inner_67_1090" x1="78.0741" y1="20.4949" x2="71.8702" y2="65.1795" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFFFFF"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="108" height="52" viewBox="0 0 108 52" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 48.494C0 50.4303 1.5697 52 3.50602 52H104.494C106.43 52 108 50.4303 108 48.494V3.50602C108 1.5697 106.43 3.73075e-09 104.494 3.73075e-09H3.50602C1.5697 3.73075e-09 0 1.5697 0 3.50602V48.494Z" fill="url(#paint0_linear_69_2375)"/>
|
||||
<path d="M1.48047 48.1332C1.48047 49.4241 2.52694 50.4706 3.81782 50.4706H104.336C105.543 50.4706 106.522 49.492 106.522 48.2849C106.522 22.4626 85.5884 1.5294 59.7661 1.5294H3.81782C2.52694 1.5294 1.48047 2.57587 1.48047 3.86675V48.1332Z" fill="url(#paint1_linear_69_2375)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_69_2375" x1="92.1156" y1="10.1048" x2="66.3799" y2="53.0473" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E0EAE1"/>
|
||||
<stop offset="0.72" stop-color="#B8CDB3"/>
|
||||
<stop offset="1" stop-color="#B8CDB3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_69_2375" x1="58.8193" y1="16.2688" x2="54.1615" y2="50.2168" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="146" height="68" viewBox="0 0 146 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 64.494C0 66.4303 1.5697 68 3.50602 68H142.494C144.43 68 146 66.4303 146 64.494V3.50602C146 1.5697 144.43 0 142.494 0H3.50603C1.5697 0 0 1.56969 0 3.50602V64.494Z" fill="url(#paint0_linear_67_67)"/>
|
||||
<path d="M2 63.6627C2 64.9535 3.04647 66 4.33735 66H141.663C142.954 66 144 64.9535 144 63.6627V52C144 24.3858 121.614 2.00002 94 2.00002H4.33735C3.04647 2.00002 2 3.04648 2 4.33736V63.6627Z" fill="url(#paint1_linear_67_67)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_67_67" x1="124.527" y1="13.2139" x2="91.4092" y2="70.3397" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E0EAE1"/>
|
||||
<stop offset="0.72" stop-color="#B8CDB3"/>
|
||||
<stop offset="1" stop-color="#B8CDB3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_67_67" x1="79.5136" y1="21.2746" x2="73.6145" y2="65.721" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1020 B |
@@ -0,0 +1,15 @@
|
||||
<svg width="108" height="52" viewBox="0 0 108 52" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 48.494C0 50.4303 1.5697 52 3.50602 52H104.494C106.43 52 108 50.4303 108 48.494V3.50602C108 1.5697 106.43 3.73075e-09 104.494 3.73075e-09H3.50602C1.5697 3.73075e-09 0 1.5697 0 3.50602V48.494Z" fill="url(#paint0_linear_69_2372)"/>
|
||||
<path d="M1.47726 48.1939C1.47795 49.4848 2.52498 50.5306 3.81586 50.5299L104.36 50.4718C105.567 50.4711 106.545 49.492 106.545 48.2849V48.2849C106.531 22.4625 85.5865 1.54149 59.7641 1.5564L3.78958 1.58873C2.4987 1.58948 1.45279 2.63655 1.45349 3.92743L1.47726 48.1939Z" fill="url(#paint1_linear_69_2372)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_69_2372" x1="92.1156" y1="10.1048" x2="66.3799" y2="53.0473" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B8CFEA"/>
|
||||
<stop offset="0.72" stop-color="#BFD6F0"/>
|
||||
<stop offset="1" stop-color="#3D86D4" stop-opacity="0.7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_69_2372" x1="58.5614" y1="15.8664" x2="53.7835" y2="50.4693" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="146" height="68" viewBox="0 0 146 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 64.494C0 66.4303 1.5697 68 3.50602 68H142.494C144.43 68 146 66.4303 146 64.494V3.50602C146 1.5697 144.43 0 142.494 0H3.50603C1.5697 0 0 1.56969 0 3.50602V64.494Z" fill="url(#paint0_linear_67_1091)"/>
|
||||
<path d="M1.9987 63.742C1.99942 65.0329 3.04647 66.0788 4.33735 66.078L141.698 66.0013C142.989 66.0006 144.035 64.9535 144.034 63.6626L144.028 52C144.012 24.3858 121.614 2.0125 94 2.02793L4.30182 2.07805C3.01094 2.07877 1.96505 3.12582 1.96576 4.4167L1.9987 63.742Z" fill="url(#paint1_linear_67_1091)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_67_1091" x1="124.527" y1="13.2139" x2="91.4092" y2="70.3397" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B8CFEA"/>
|
||||
<stop offset="0.72" stop-color="#BFD6F0"/>
|
||||
<stop offset="1" stop-color="#3D86D4" stop-opacity="0.7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_67_1091" x1="79.1676" y1="20.7484" x2="73.1165" y2="66.0528" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="108" height="52" viewBox="0 0 108 52" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 48.494C0 50.4303 1.5697 52 3.50602 52H104.494C106.43 52 108 50.4303 108 48.494V3.50602C108 1.5697 106.43 0 104.494 0H3.50602C1.5697 0 0 1.5697 0 3.50602V48.494Z" fill="url(#paint0_linear_69_2373)"/>
|
||||
<path d="M1.47852 48.1332C1.47852 49.4241 2.52498 50.4706 3.81587 50.4706H104.334C105.541 50.4706 106.52 49.492 106.52 48.2849C106.52 22.4626 85.5865 1.52939 59.7641 1.52939H3.81587C2.52499 1.52939 1.47852 2.57586 1.47852 3.86674V48.1332Z" fill="url(#paint1_linear_69_2373)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_69_2373" x1="92.1156" y1="10.1048" x2="66.3799" y2="53.0473" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DFDEE9"/>
|
||||
<stop offset="0.72" stop-color="#AFA4C3"/>
|
||||
<stop offset="1" stop-color="#7F6B9D" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_69_2373" x1="58.4371" y1="16.1449" x2="53.7525" y2="50.215" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="146" height="68" viewBox="0 0 146 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 64.494C0 66.4303 1.5697 68 3.50602 68H142.494C144.43 68 146 66.4303 146 64.494V3.50603C146 1.5697 144.43 0 142.494 0H3.50603C1.5697 0 0 1.5697 0 3.50603V64.494Z" fill="url(#paint0_linear_67_51)"/>
|
||||
<path d="M2 63.6627C2 64.9536 3.04647 66 4.33735 66H141.663C142.954 66 144 64.9536 144 63.6627V52C144 24.3858 121.614 2.00002 94 2.00002H4.33735C3.04647 2.00002 2 3.04648 2 4.33736V63.6627Z" fill="url(#paint1_linear_67_51)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_67_51" x1="124.527" y1="13.2139" x2="91.4092" y2="70.3397" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DFDEE9"/>
|
||||
<stop offset="0.72" stop-color="#AFA4C3"/>
|
||||
<stop offset="1" stop-color="#7F6B9D" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_67_51" x1="78.9996" y1="21.1126" x2="73.0666" y2="65.719" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" fill="#f1f5f8"/>
|
||||
<circle cx="32" cy="23" r="10" fill="#8c9ead"/>
|
||||
<path d="M14 54v-3c0-10 8-16 18-16s18 6 18 16v3H14Z" fill="#8c9ead"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,32 @@
|
||||
<svg width="1758" height="901" viewBox="0 0 1758 901" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1334.26 434.607C1325.83 686.168 1115.1 883.287 863.544 874.862C611.983 866.436 414.864 655.706 423.289 404.145C431.715 152.584 642.444 -44.535 894.005 -36.2021C1145.57 -27.7766 1342.69 182.953 1334.26 434.514V434.607Z" stroke="#CFE3F6" stroke-width="0.663562"/>
|
||||
<path d="M1317.01 434.028C1308.91 676.065 1106.16 865.721 864.121 857.614C622.084 849.508 432.428 646.757 440.535 404.72C448.641 162.684 651.392 -26.9719 893.429 -18.9545C1135.46 -10.848 1325.12 191.903 1317.01 433.939V434.028Z" stroke="#CFE3F6" stroke-width="0.663562"/>
|
||||
<path d="M1300.56 433.477C1292.76 666.428 1097.62 848.965 864.671 841.163C631.72 833.361 449.183 638.22 456.985 405.269C464.787 172.318 659.927 -10.2191 892.879 -2.50268C1125.83 5.29951 1308.37 200.44 1300.56 433.391V433.477Z" stroke="#CFE3F6" stroke-width="0.663562"/>
|
||||
<path d="M1281.35 432.833C1273.91 655.173 1087.65 829.395 865.313 821.948C642.973 814.501 468.751 628.25 476.197 405.91C483.644 183.569 669.896 9.34707 892.236 16.712C1114.58 24.1588 1288.8 210.411 1281.35 432.751V432.833Z" stroke="#CFE3F6" stroke-width="0.663562"/>
|
||||
<path d="M1260.18 432.123C1253.13 642.771 1076.67 807.832 866.021 800.776C655.373 793.721 490.312 617.264 497.367 406.615C504.423 195.967 680.88 30.9065 891.528 37.8842C1102.18 44.9394 1267.24 221.397 1260.18 432.045V432.123Z" stroke="#CFE3F6" stroke-width="0.663562"/>
|
||||
<path d="M1233.13 431.215C1226.57 626.922 1062.63 780.274 866.926 773.72C671.219 767.165 517.867 603.224 524.422 407.517C530.976 211.811 694.917 58.4586 890.624 64.9413C1086.33 71.4961 1239.68 235.437 1233.13 431.143V431.215Z" stroke="#CFE3F6" stroke-width="0.663562"/>
|
||||
<path d="M1200.59 430.124C1194.64 607.86 1045.75 747.131 868.014 741.178C690.278 735.225 551.007 586.338 556.96 408.602C562.913 230.867 711.8 91.5959 889.535 97.4834C1067.27 103.436 1206.54 252.323 1200.59 430.059V430.124Z" stroke="#CFE3F6" stroke-width="0.663562"/>
|
||||
<path d="M1350.93 435.148C1342.22 695.968 1123.72 900.309 862.989 891.606C602.169 882.903 397.828 664.488 406.531 403.668C415.235 142.849 633.742 -61.4927 894.469 -52.7895C1155.29 -44.0862 1359.63 174.421 1350.93 435.148Z" stroke="#D6E9F8" stroke-width="0.663562"/>
|
||||
<path d="M1367.59 435.713C1358.61 705.699 1132.42 917.262 862.341 908.281C592.355 899.3 380.792 673.108 389.773 403.03C398.754 133.044 624.946 -78.6117 895.025 -69.5381C1165.01 -60.5571 1376.67 165.727 1367.59 435.713Z" stroke="#D6E9F8" stroke-opacity="0.9" stroke-width="0.663562"/>
|
||||
<path d="M1384.35 436.269C1375 715.514 1141.12 934.299 861.878 924.947C582.633 915.596 363.848 681.719 373.199 402.474C382.551 123.23 616.428 -95.5552 895.672 -86.2964C1174.92 -76.9451 1393.7 156.932 1384.35 436.176V436.269Z" stroke="#D6E9F8" stroke-opacity="0.9" stroke-width="0.663562"/>
|
||||
<path d="M1401.02 436.829C1391.39 725.332 1149.73 951.339 861.323 941.71C572.912 932.081 346.812 690.427 356.441 402.016C366.071 113.512 607.725 -112.494 896.136 -102.865C1184.55 -93.2361 1410.65 148.418 1401.02 436.829Z" stroke="#D6E9F8" stroke-opacity="0.8" stroke-width="0.663562"/>
|
||||
<path d="M1417.68 437.366C1407.78 735.036 1158.34 968.357 860.674 958.358C563.004 948.451 329.776 699.019 339.683 401.35C349.59 103.68 599.021 -129.641 896.691 -119.642C1194.36 -109.735 1427.68 139.697 1417.68 437.366Z" stroke="#D6E9F8" stroke-opacity="0.8" stroke-width="0.663562"/>
|
||||
<path d="M1434.44 437.94C1424.16 744.869 1167.14 985.319 860.211 975.042C553.283 964.765 312.832 707.741 323.11 400.813C333.387 93.8843 590.503 -146.566 897.339 -136.381C1204.27 -126.104 1444.72 131.012 1434.44 437.848V437.94Z" stroke="#D6E9F8" stroke-opacity="0.7" stroke-width="0.663562"/>
|
||||
<path d="M1451.11 438.489C1440.55 754.584 1175.75 1002.35 859.563 991.793C543.469 981.238 295.704 716.437 306.259 400.25C316.814 84.1555 581.615 -163.609 897.802 -153.054C1213.9 -142.499 1461.66 122.302 1451.11 438.489Z" stroke="#D6E9F8" stroke-opacity="0.7" stroke-width="0.663562"/>
|
||||
<path d="M1467.87 439.047C1457.03 764.4 1184.45 1019.39 859.101 1008.46C533.747 997.629 278.853 725.05 289.686 399.697C300.519 74.3436 573.097 -180.643 898.451 -169.718C1223.8 -158.885 1478.79 113.693 1467.87 439.047Z" stroke="#D6E9F8" stroke-opacity="0.6" stroke-width="0.663562"/>
|
||||
<path d="M1484.53 439.6C1473.33 774.212 1193.06 1036.33 858.544 1025.22C524.024 1014.01 261.816 733.751 272.926 399.232C284.129 64.6196 564.393 -197.589 898.912 -186.386C1233.43 -175.183 1495.64 105.08 1484.44 439.6H1484.53Z" stroke="#D6E9F8" stroke-opacity="0.6" stroke-width="0.663562"/>
|
||||
<path d="M1501.2 440.162C1489.72 783.941 1201.68 1053.37 857.898 1041.89C514.119 1030.41 244.689 742.462 256.17 398.591C267.743 54.8121 555.691 -214.526 899.562 -203.045C1243.34 -191.564 1512.77 96.4766 1501.29 440.255L1501.2 440.162Z" stroke="#D6E9F8" stroke-opacity="0.5" stroke-width="0.663562"/>
|
||||
<path d="M1517.95 440.713C1506.2 793.751 1210.47 1070.4 857.433 1058.55C504.396 1046.79 227.837 751.068 239.595 398.03C251.354 45.0858 547.079 -231.566 900.116 -219.808C1253.15 -208.049 1529.81 87.6762 1517.95 440.713Z" stroke="#D6E9F8" stroke-opacity="0.5" stroke-width="0.663562"/>
|
||||
<path d="M1534.62 441.355C1522.49 803.651 1219.08 1087.43 856.878 1075.4C494.674 1063.27 210.8 759.857 222.837 397.653C234.966 35.2646 538.376 -248.61 900.672 -236.481C1262.88 -224.352 1546.75 79.0587 1534.62 441.355Z" stroke="#D6E9F8" stroke-opacity="0.5" stroke-width="0.663562"/>
|
||||
<path d="M1551.29 441.908C1538.88 813.37 1227.69 1104.47 856.23 1092.06C484.768 1079.65 193.672 768.465 206.079 397.003C218.578 25.4479 529.766 -265.555 901.228 -253.149C1272.69 -240.742 1563.79 70.4455 1551.38 441.908H1551.29Z" stroke="#D6E9F8" stroke-opacity="0.5" stroke-width="0.663562"/>
|
||||
<path d="M1568.04 442.467C1555.36 823.188 1236.4 1121.51 855.767 1108.73C475.046 1096.04 176.728 777.079 189.413 396.451C202.097 15.7302 521.062 -282.588 901.783 -269.903C1282.41 -257.219 1580.82 61.7463 1568.04 442.375V442.467Z" stroke="#D6E9F8" stroke-opacity="0.5" stroke-width="0.663562"/>
|
||||
<path d="M1584.71 443.026C1571.66 832.913 1245.1 1138.45 855.12 1125.49C465.233 1112.44 159.694 785.786 172.748 395.899C185.711 5.91949 512.453 -299.62 902.34 -286.565C1292.23 -273.51 1597.77 53.1393 1584.71 443.026Z" stroke="#D6E9F8" stroke-opacity="0.5" stroke-width="0.663562"/>
|
||||
<path d="M1601.38 443.581C1588.04 842.727 1253.71 1155.49 854.564 1142.16C455.511 1128.82 142.749 794.396 155.989 395.343C169.322 -3.80306 503.749 -316.565 902.895 -303.324C1302.04 -289.992 1614.8 44.3425 1601.47 443.488L1601.38 443.581Z" stroke="#D6E9F8" stroke-opacity="0.5" stroke-width="0.663562"/>
|
||||
<path d="M1619.26 444.179C1605.6 853.201 1262.99 1173.7 853.966 1160.04C445.038 1146.38 124.538 803.675 138.105 394.747C151.768 -14.2755 494.47 -334.776 903.493 -321.208C1312.52 -307.546 1633.02 35.0614 1619.35 444.084L1619.26 444.179Z" stroke="#D6E9F8" stroke-opacity="0.5" stroke-width="0.663562"/>
|
||||
<path d="M1638.07 444.808C1624.06 864.223 1272.75 1192.87 853.337 1178.86C434.019 1164.85 105.375 813.438 119.287 394.12C133.297 -25.2949 484.707 -353.939 904.122 -340.027C1323.54 -326.017 1652.18 25.2956 1638.17 444.711L1638.07 444.808Z" stroke="#D6E9F8" stroke-opacity="0.5" stroke-width="0.663562"/>
|
||||
<path d="M1659.66 445.53C1645.26 876.87 1283.95 1214.86 852.615 1200.45C421.374 1186.04 83.3858 824.641 97.6938 393.401C112.102 -37.9395 473.503 -375.928 904.844 -361.62C1336.18 -347.212 1674.17 14.0895 1659.76 445.43L1659.66 445.53Z" stroke="#D6E9F8" stroke-opacity="0.4" stroke-width="0.663562"/>
|
||||
<path d="M1682.94 446.308C1668.1 890.503 1296.03 1238.56 851.836 1223.73C407.744 1208.89 59.6834 836.717 74.4177 392.626C89.2552 -51.5694 461.427 -399.63 905.622 -384.896C1349.82 -370.059 1697.88 2.0101 1683.04 446.205L1682.94 446.308Z" stroke="#D6E9F8" stroke-opacity="0.4" stroke-width="0.663562"/>
|
||||
<path d="M1708.4 447.16C1693.09 905.419 1309.24 1264.5 850.985 1249.19C392.832 1233.89 33.7502 849.93 48.9511 391.777C64.2583 -66.482 448.214 -425.563 906.473 -410.362C1364.73 -395.055 1723.81 -11.206 1708.51 447.053L1708.4 447.16Z" stroke="#D6E9F8" stroke-opacity="0.3" stroke-width="0.663562"/>
|
||||
<path d="M1731.81 447.942C1716.07 919.134 1321.39 1288.35 850.201 1272.61C379.119 1256.87 9.90427 862.08 25.5341 390.997C41.2733 -80.1944 436.064 -449.409 907.256 -433.779C1378.45 -418.04 1747.66 -23.3586 1731.92 447.833L1731.81 447.942Z" stroke="#D6E9F8" stroke-opacity="0.3" stroke-width="0.663562"/>
|
||||
<path d="M1756.52 448.769C1740.33 933.607 1334.21 1313.51 849.375 1297.32C364.65 1281.12 -15.2584 874.9 0.824129 390.174C17.0191 -94.664 423.244 -474.572 908.082 -458.489C1392.92 -442.294 1772.83 -36.182 1756.63 448.656L1756.52 448.769Z" stroke="#D6E9F8" stroke-opacity="0.3" stroke-width="0.663562"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,11 @@
|
||||
# Workdesk Full Composition artwork
|
||||
|
||||
These nine files are copied byte-for-byte from the read-only source
|
||||
`D:\디자인시스템\gpd-workdesk\sample\조직도_v1.3\public`.
|
||||
They are embedded by `?inline` imports in the shared Core ESM/IIFE bundles, so
|
||||
HTML and React need no external Workdesk server or asset URL.
|
||||
|
||||
The compact `screenD` hub paths and original arrow placements are implemented
|
||||
in `core/workdesk-artwork.ts`. Lower organization relationships retain the
|
||||
existing engine routes because the source lower SVG block is hidden.
|
||||
Approved card SVGs and company logo files are untouched.
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 275 KiB |
@@ -0,0 +1,24 @@
|
||||
import { createOrgChartView } from './core/index.js';
|
||||
import type { OrgChartHandle, OrgChartOptions } from './core/types.js';
|
||||
|
||||
/** Standalone summaries share the same source, validation, update/reload and
|
||||
* dispose contract as OrgChart. Only the independent core CSS is required. */
|
||||
export interface OrgChartSummaryComponentOptions extends Pick<OrgChartOptions, 'container' | 'dataSource' | 'aggregationPolicy' | 'onStateChange'> {
|
||||
stylesheetUrl: string;
|
||||
}
|
||||
function summaryOptions(options: OrgChartSummaryComponentOptions): OrgChartOptions {
|
||||
return { ...options, stylesheetUrls: { core: options.stylesheetUrl, engine: '' } };
|
||||
}
|
||||
export function createPositionSummary(options: OrgChartSummaryComponentOptions): OrgChartHandle {
|
||||
return createOrgChartView(summaryOptions(options), 'positionSummary');
|
||||
}
|
||||
export function createAffiliationSummary(options: OrgChartSummaryComponentOptions): OrgChartHandle {
|
||||
return createOrgChartView(summaryOptions(options), 'affiliationSummary');
|
||||
}
|
||||
export function createStatusSummary(options: OrgChartSummaryComponentOptions): OrgChartHandle {
|
||||
return createOrgChartView(summaryOptions(options), 'statusSummary');
|
||||
}
|
||||
/** Tree-only component: no title, leader composition, hub or summary modules. */
|
||||
export function createOrgChartTree(options: OrgChartOptions): OrgChartHandle {
|
||||
return createOrgChartView(options, 'tree');
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { selectAffiliationSummary } from '../data/content-selectors.js';
|
||||
import type { RenderContext } from './render.js';
|
||||
import { createAffiliationMark } from './affiliation-mark.js';
|
||||
|
||||
/** Read-only composition presenter: one untitled line of logo · name · count and
|
||||
* the total. The standalone AffiliationSummary keeps its titled card layout;
|
||||
* both use the same selector and Summary counts. */
|
||||
export function createAffiliationBar(document: Document, context: RenderContext, signal: AbortSignal): HTMLElement {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'hm-orgchart-affiliation-bar';
|
||||
section.dataset.contentSection = 'affiliation-bar';
|
||||
section.setAttribute('aria-label', '관계사별 인원 현황');
|
||||
const list = document.createElement('dl');
|
||||
list.className = 'hm-orgchart-affiliation-bar-list';
|
||||
for (const { affiliation, count } of selectAffiliationSummary(context.data, context.summary)) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'hm-orgchart-affiliation-bar-item';
|
||||
item.dataset.affiliationId = affiliation.id;
|
||||
item.dataset.employeeCount = String(count);
|
||||
const name = document.createElement('dt');
|
||||
name.textContent = affiliation.name;
|
||||
name.prepend(createAffiliationMark(document, affiliation.id, affiliation.name, signal));
|
||||
const value = document.createElement('dd');
|
||||
value.textContent = `${count}명`;
|
||||
item.append(name, value);
|
||||
list.append(item);
|
||||
}
|
||||
const total = document.createElement('div');
|
||||
total.className = 'hm-orgchart-affiliation-bar-item hm-orgchart-affiliation-bar-total';
|
||||
total.dataset.totalCount = String(context.summary.raw.total);
|
||||
const label = document.createElement('dt'); label.textContent = '총계';
|
||||
const value = document.createElement('dd'); value.textContent = `${context.summary.raw.total}명`;
|
||||
total.append(label, value);
|
||||
list.append(total);
|
||||
section.append(list);
|
||||
return section;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import hanmac from '../assets/affiliations/hanmac.png?inline';
|
||||
import saman from '../assets/affiliations/saman.png?inline';
|
||||
import jangheon from '../assets/affiliations/jangheon.png?inline';
|
||||
import jangheonIndustry from '../assets/affiliations/jangheon-industry.png?inline';
|
||||
import ptc from '../assets/affiliations/ptc.png?inline';
|
||||
import halla from '../assets/affiliations/halla.png?inline';
|
||||
import baron from '../assets/affiliations/baron.png?inline';
|
||||
|
||||
// Asset mapping is an internal presentation concern; the employee/affiliation
|
||||
// JSON contract continues to use only its existing id and name fields.
|
||||
const logos = new Map<string, string>([
|
||||
['hanmac', hanmac], ['saman', saman], ['jangheon', jangheon],
|
||||
['jangheon-industry', jangheonIndustry], ['ptc', ptc], ['halla', halla], ['baron', baron],
|
||||
]);
|
||||
|
||||
/** Shared by employee cards and the affiliation summary footer. */
|
||||
export function createAffiliationMark(
|
||||
document: Document, affiliationId: string, name: string, signal?: AbortSignal,
|
||||
): HTMLElement {
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'hm-orgchart-affiliation-mark';
|
||||
mark.dataset.affiliationId = affiliationId;
|
||||
mark.setAttribute('role', 'img');
|
||||
mark.setAttribute('aria-label', `관계사 ${name}`);
|
||||
mark.title = name;
|
||||
const fallback = document.createElement('span');
|
||||
fallback.className = 'hm-orgchart-affiliation-fallback';
|
||||
fallback.textContent = Array.from(name.trim()).slice(0, 2).join('') || '?';
|
||||
mark.append(fallback);
|
||||
|
||||
const logo = logos.get(affiliationId);
|
||||
mark.dataset.logoAvailable = String(Boolean(logo));
|
||||
if (logo) {
|
||||
const image = document.createElement('img');
|
||||
image.alt = '';
|
||||
image.decoding = 'async';
|
||||
image.addEventListener('error', () => {
|
||||
image.remove();
|
||||
mark.dataset.logoAvailable = 'false';
|
||||
}, { once: true, ...(signal ? { signal } : {}) });
|
||||
image.src = logo;
|
||||
mark.append(image);
|
||||
}
|
||||
return mark;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { selectAffiliationSummary } from '../data/content-selectors.js';
|
||||
import type { RenderContext } from './render.js';
|
||||
import { createAffiliationMark } from './affiliation-mark.js';
|
||||
|
||||
export function createAffiliationSummary(document: Document, context: RenderContext): HTMLElement {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'hm-orgchart-affiliation-summary';
|
||||
section.dataset.contentSection = 'affiliation-summary';
|
||||
section.setAttribute('aria-label', '관계사별 원본 인원 현황');
|
||||
const heading = document.createElement('h3');
|
||||
heading.className = 'hm-orgchart-summary-title';
|
||||
heading.textContent = '관계사별 인원 현황';
|
||||
const total = document.createElement('p');
|
||||
total.className = 'hm-orgchart-raw-total';
|
||||
total.textContent = `Raw Count · 전체 ${context.summary.raw.total}명`;
|
||||
section.append(heading, total);
|
||||
const list = document.createElement('dl');
|
||||
list.className = 'hm-orgchart-affiliation-list';
|
||||
for (const { affiliation, count } of selectAffiliationSummary(context.data, context.summary)) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'hm-orgchart-affiliation-item';
|
||||
item.dataset.affiliationId = affiliation.id;
|
||||
const name = document.createElement('dt');
|
||||
name.className = 'hm-orgchart-affiliation-name';
|
||||
// The contract has no logo field. The declared name is the safe textual fallback.
|
||||
name.textContent = affiliation.name;
|
||||
name.prepend(createAffiliationMark(document, affiliation.id, affiliation.name));
|
||||
const value = document.createElement('dd');
|
||||
value.className = 'hm-orgchart-affiliation-count';
|
||||
value.textContent = `${count}명`;
|
||||
item.append(name, value);
|
||||
list.append(item);
|
||||
}
|
||||
section.append(list);
|
||||
if (!context.data.affiliations.length) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'hm-orgchart-content-empty';
|
||||
empty.textContent = '등록된 관계사가 없습니다.';
|
||||
section.append(empty);
|
||||
}
|
||||
return section;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { contrastRatio, evaluateGradientContrast, parseOpaqueColor, parseVerticalGradient, relativeLuminance, textContrastThreshold } from './contrast.js';
|
||||
|
||||
const near = (actual: number, expected: number, tolerance = 0.001) => assert.ok(Math.abs(actual - expected) < tolerance, `${actual} ≈ ${expected}`);
|
||||
const integrated = 'linear-gradient(180deg, #8c67b6 0%, #5d3494 100%)';
|
||||
const design = 'linear-gradient(180deg, rgb(9, 167, 162) 0%, rgb(0, 132, 127) 100%)';
|
||||
|
||||
test('standard sRGB luminance and contrast use unrounded channels with symmetric ratios', () => {
|
||||
near(relativeLuminance([0, 0, 0]), 0);
|
||||
near(relativeLuminance([255, 255, 255]), 1);
|
||||
near(contrastRatio([0, 0, 0], [255, 255, 255]), 21);
|
||||
near(contrastRatio([16, 36, 59], [255, 255, 255]), 15.7005);
|
||||
near(contrastRatio([89, 101, 117], [244, 247, 251]), 5.5160);
|
||||
assert.equal(contrastRatio([16, 36, 59], [255, 255, 255]), contrastRatio([255, 255, 255], [16, 36, 59]));
|
||||
});
|
||||
|
||||
test('color parser accepts opaque HEX and computed RGB, rejects transparent/unknown compositing', () => {
|
||||
assert.deepEqual(parseOpaqueColor('#09a7a2'), [9, 167, 162]);
|
||||
assert.deepEqual(parseOpaqueColor('#fff'), [255, 255, 255]);
|
||||
assert.deepEqual(parseOpaqueColor('rgb(16 36 59)'), [16, 36, 59]);
|
||||
assert.deepEqual(parseOpaqueColor('rgba(16, 36, 59, 1)'), [16, 36, 59]);
|
||||
for (const value of ['rgba(0, 0, 0, 0.5)', '#ffffff80', 'transparent', 'red', 'rgb(100% 0% 0%)', 'color(display-p3 1 0 0)', 'rgb(256, 0, 0)']) assert.equal(parseOpaqueColor(value), null);
|
||||
});
|
||||
|
||||
test('large text threshold uses measured size/weight and does not treat 15px bold as large', () => {
|
||||
assert.equal(textContrastThreshold(15, 700), 4.5);
|
||||
assert.equal(textContrastThreshold(19.5, 700), 3);
|
||||
assert.equal(textContrastThreshold(18.6, 700), 4.5);
|
||||
assert.equal(textContrastThreshold(14 * 96 / 72, 700), 3);
|
||||
assert.equal(textContrastThreshold(19.5, 500), 4.5);
|
||||
assert.equal(textContrastThreshold(24, 400), 3);
|
||||
});
|
||||
|
||||
test('restricted gradient parser preserves RGB commas and explicit ordered percent stops', () => {
|
||||
assert.equal(parseVerticalGradient(design)!.stops.length, 2);
|
||||
assert.deepEqual(parseVerticalGradient(design)!.stops[0]!.color, [9, 167, 162]);
|
||||
assert.equal(parseVerticalGradient('linear-gradient(180deg, #000 0%, #fff 25%, #000 100%)')!.stops[1]!.offset, 0.25);
|
||||
for (const value of ['linear-gradient(90deg, #000 0%, #fff 100%)', 'linear-gradient(180deg in oklab, #000 0%, #fff 100%)', 'linear-gradient(180deg, #000, #fff)', 'linear-gradient(180deg, #000 0px, #fff 100px)', 'linear-gradient(180deg, #000 0%, #fff 50%)', 'linear-gradient(180deg, #000 0%, #fff 0%, #000 100%)', 'linear-gradient(180deg, #000 0%, #fff 100%), url(x)', 'url(image.png)', 'repeating-linear-gradient(180deg, #000 0%, #fff 100%)']) assert.equal(parseVerticalGradient(value), null, value);
|
||||
});
|
||||
|
||||
test('browser-computed omitted default angle is equivalent to 180deg, without inferring omitted stops', () => {
|
||||
const computed = 'linear-gradient(rgb(9, 167, 162) 0%, rgb(0, 132, 127) 100%)';
|
||||
assert.deepEqual(parseVerticalGradient(computed), parseVerticalGradient(design));
|
||||
const input = { foreground: '#fff', ranges: [{ top: 6, bottom: 24 }], backgroundHeight: 30 };
|
||||
assert.deepEqual(evaluateGradientContrast({ ...input, backgroundImage: computed }), evaluateGradientContrast({ ...input, backgroundImage: design }));
|
||||
assert.equal(parseVerticalGradient('linear-gradient(rgb(9, 167, 162), rgb(0, 132, 127))'), null);
|
||||
});
|
||||
|
||||
test('inset actual text model can pass while a conservative stop value is below 4.5', () => {
|
||||
const result = evaluateGradientContrast({ foreground: '#fff', backgroundImage: integrated, ranges: [{ top: 6, bottom: 24 }], backgroundHeight: 30 });
|
||||
assert.equal(result.status, 'MODEL');
|
||||
near(result.stopMinimum!, 4.4391);
|
||||
assert.ok(result.stopMinimum! < 4.5);
|
||||
assert.ok(result.textRegionMinimum! > 5);
|
||||
assert.ok(result.textRegionMinimum! > result.fullGradientMinimum!);
|
||||
});
|
||||
|
||||
test('current large title and normal Full title thresholds remain independent for the same actual area', () => {
|
||||
const result = evaluateGradientContrast({ foreground: '#fff', backgroundImage: design, ranges: [{ top: 6, bottom: 24 }], backgroundHeight: 30 });
|
||||
assert.ok(result.textRegionMinimum! >= textContrastThreshold(19.5, 700));
|
||||
assert.ok(result.textRegionMinimum! < textContrastThreshold(15, 700));
|
||||
const black = evaluateGradientContrast({ foreground: '#000', backgroundImage: design, ranges: [{ top: 0, bottom: 30 }], backgroundHeight: 30 });
|
||||
near(black.fullGradientMinimum!, 4.6070);
|
||||
assert.ok(black.fullGradientMinimum! >= 4.5);
|
||||
});
|
||||
|
||||
test('all actual line ranges are included, preserving a weak wrapped line', () => {
|
||||
const first = evaluateGradientContrast({ foreground: '#fff', backgroundImage: integrated, ranges: [{ top: 60, bottom: 80 }], backgroundHeight: 100 });
|
||||
const wrapped = evaluateGradientContrast({ foreground: '#fff', backgroundImage: integrated, ranges: [{ top: 60, bottom: 80 }, { top: 0, bottom: 10 }], backgroundHeight: 100 });
|
||||
assert.ok(first.textRegionMinimum! > 4.5);
|
||||
near(wrapped.textRegionMinimum!, wrapped.stopMinimum!);
|
||||
assert.ok(wrapped.textRegionMinimum! < first.textRegionMinimum!);
|
||||
});
|
||||
|
||||
test('stop minima never substitute for whole-gradient sampling when foreground meets an interior color', () => {
|
||||
const result = evaluateGradientContrast({ foreground: '#777', backgroundImage: 'linear-gradient(180deg, #000 0%, #fff 100%)', ranges: [{ top: 40, bottom: 55 }], backgroundHeight: 100 });
|
||||
assert.ok(result.stopMinimum! > 4);
|
||||
assert.ok(result.fullGradientMinimum! < 1.02);
|
||||
assert.ok(result.textRegionMinimum! < 1.03);
|
||||
});
|
||||
|
||||
test('unsupported backgrounds, alpha, missing/clipped ranges and unbounded sample requests remain NOT VERIFIED', () => {
|
||||
const valid = { foreground: '#fff', backgroundImage: integrated, ranges: [{ top: 2, bottom: 20 }], backgroundHeight: 30 };
|
||||
for (const input of [
|
||||
{ ...valid, foreground: 'rgba(255, 255, 255, 0.5)' }, { ...valid, backgroundImage: 'url(x)' },
|
||||
{ ...valid, ranges: [] }, { ...valid, ranges: [{ top: -1, bottom: 20 }] },
|
||||
{ ...valid, ranges: [{ top: 2, bottom: 31 }] }, { ...valid, ranges: [{ top: 20, bottom: 2 }] },
|
||||
{ ...valid, backgroundHeight: NaN }, { ...valid, backgroundHeight: 0 }, { ...valid, sampleStep: 0 },
|
||||
{ ...valid, sampleStep: 0.000001 },
|
||||
]) {
|
||||
const result = evaluateGradientContrast(input);
|
||||
assert.equal(result.status, 'NOT VERIFIED');
|
||||
assert.equal(result.textRegionMinimum, undefined);
|
||||
assert.ok(result.reason);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/** Internal contrast arithmetic. Results model opaque legacy sRGB colors;
|
||||
* they are not pixel measurements or a claim about every rendered instance. */
|
||||
export type Rgb = readonly [number, number, number];
|
||||
export interface VerticalGradient { stops: readonly { color: Rgb; offset: number }[] }
|
||||
export interface ContrastModel {
|
||||
status: 'MODEL' | 'NOT VERIFIED';
|
||||
reason?: string;
|
||||
stopMinimum?: number;
|
||||
fullGradientMinimum?: number;
|
||||
textRegionMinimum?: number;
|
||||
}
|
||||
|
||||
export function parseOpaqueColor(value: string): Rgb | null {
|
||||
const text = value.trim();
|
||||
if (/^#[\da-f]{6}$/i.test(text)) return [1, 3, 5].map(index => parseInt(text.slice(index, index + 2), 16)) as unknown as Rgb;
|
||||
if (/^#[\da-f]{3}$/i.test(text)) return [...text.slice(1)].map(part => parseInt(part + part, 16)) as unknown as Rgb;
|
||||
const match = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+))?\s*\)$/i.exec(text);
|
||||
if (!match || (match[4] !== undefined && Number(match[4]) !== 1)) return null;
|
||||
const channels = match.slice(1, 4).map(Number);
|
||||
return channels.every(channel => Number.isFinite(channel) && channel >= 0 && channel <= 255) ? channels as unknown as Rgb : null;
|
||||
}
|
||||
|
||||
export function relativeLuminance(color: Rgb): number {
|
||||
const channels = color.map(channel => {
|
||||
const encoded = channel / 255;
|
||||
return encoded <= 0.04045 ? encoded / 12.92 : ((encoded + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722;
|
||||
}
|
||||
|
||||
export function contrastRatio(foreground: Rgb, background: Rgb): number {
|
||||
const first = relativeLuminance(foreground), second = relativeLuminance(background);
|
||||
return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
|
||||
}
|
||||
|
||||
/** Restricted intentionally: no alpha, images, multiple layers, omitted stops,
|
||||
* other angles, color spaces, relative units, or repeating gradients.
|
||||
* Computed CSS may omit the default 180deg angle; that is equivalent. */
|
||||
export function parseVerticalGradient(value: string): VerticalGradient | null {
|
||||
const outer = /^linear-gradient\(\s*(?:180deg\s*,\s*)?(.*)\)$/i.exec(value.trim());
|
||||
if (!outer) return null;
|
||||
const parts = outer[1]!.split(/,(?![^()]*\))/);
|
||||
const stops: { color: Rgb; offset: number }[] = [];
|
||||
for (const part of parts) {
|
||||
const match = /^\s*(#[\da-f]+|rgba?\([^()]*\))\s+([\d.]+)%\s*$/i.exec(part);
|
||||
const color = match && parseOpaqueColor(match[1]!);
|
||||
const offset = match ? Number(match[2]) / 100 : NaN;
|
||||
if (!color || !Number.isFinite(offset) || offset < 0 || offset > 1 || (stops.length > 0 && offset <= stops.at(-1)!.offset)) return null;
|
||||
stops.push({ color, offset });
|
||||
}
|
||||
return stops.length >= 2 && stops[0]!.offset === 0 && stops.at(-1)!.offset === 1 ? { stops } : null;
|
||||
}
|
||||
|
||||
function gradientColor(gradient: VerticalGradient, offset: number): Rgb {
|
||||
let index = 1;
|
||||
while (index < gradient.stops.length - 1 && offset > gradient.stops[index]!.offset) index++;
|
||||
const before = gradient.stops[index - 1]!, after = gradient.stops[index]!;
|
||||
const amount = (offset - before.offset) / (after.offset - before.offset);
|
||||
return before.color.map((channel, channelIndex) => channel + (after.color[channelIndex]! - channel) * amount) as unknown as Rgb;
|
||||
}
|
||||
|
||||
export function textContrastThreshold(fontSizePx: number, fontWeight: number): 3 | 4.5 {
|
||||
return fontSizePx >= 24 || (fontWeight >= 700 && fontSizePx >= 14 * 96 / 72) ? 3 : 4.5;
|
||||
}
|
||||
|
||||
/** Ranges are actual text line rectangles in CSS px relative to the gradient
|
||||
* painting box, not the padded heading rectangle. The whole-gradient sample
|
||||
* and stop sample are reported separately; neither substitutes for text area. */
|
||||
export function evaluateGradientContrast(input: {
|
||||
foreground: string;
|
||||
backgroundImage: string;
|
||||
ranges: readonly { top: number; bottom: number }[];
|
||||
backgroundHeight: number;
|
||||
sampleStep?: number;
|
||||
}): ContrastModel {
|
||||
const foreground = parseOpaqueColor(input.foreground), gradient = parseVerticalGradient(input.backgroundImage);
|
||||
if (!foreground || !gradient) return { status: 'NOT VERIFIED', reason: '지원하지 않는 색상/gradient: 불투명 sRGB 180deg 및 명시적 % stop만 지원' };
|
||||
const height = input.backgroundHeight, step = input.sampleStep ?? 1;
|
||||
if (!Number.isFinite(height) || height <= 0 || !Number.isFinite(step) || step <= 0 || height / step > 10000 || input.ranges.length === 0 || input.ranges.some(range => !Number.isFinite(range.top) || !Number.isFinite(range.bottom) || range.top < 0 || range.bottom > height || range.bottom <= range.top)) {
|
||||
return { status: 'NOT VERIFIED', reason: '텍스트 범위/배경 높이/샘플 간격 확인 불가' };
|
||||
}
|
||||
const ratioAt = (offset: number) => contrastRatio(foreground, gradientColor(gradient, offset));
|
||||
const stopMinimum = Math.min(...gradient.stops.map(stop => contrastRatio(foreground, stop.color)));
|
||||
let fullGradientMinimum = stopMinimum;
|
||||
for (let index = 0; index <= 256; index++) fullGradientMinimum = Math.min(fullGradientMinimum, ratioAt(index / 256));
|
||||
let textRegionMinimum = Infinity;
|
||||
for (const range of input.ranges) {
|
||||
textRegionMinimum = Math.min(textRegionMinimum, ratioAt(range.top / height), ratioAt(range.bottom / height));
|
||||
for (let y = range.top + step; y < range.bottom; y += step) textRegionMinimum = Math.min(textRegionMinimum, ratioAt(y / height));
|
||||
for (const stop of gradient.stops) if (stop.offset * height >= range.top && stop.offset * height <= range.bottom) textRegionMinimum = Math.min(textRegionMinimum, ratioAt(stop.offset));
|
||||
}
|
||||
return { status: 'MODEL', stopMinimum, fullGradientMinimum, textRegionMinimum };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import businessLeader from '../assets/cards/경영기획팀.svg?inline';
|
||||
import businessEmployee from '../assets/cards/경영기획팀-sm.svg?inline';
|
||||
import managementLeader from '../assets/cards/사업관리팀.svg?inline';
|
||||
import managementEmployee from '../assets/cards/사업관리팀-sm.svg?inline';
|
||||
import designLeader from '../assets/cards/디자인기획팀.svg?inline';
|
||||
import designEmployee from '../assets/cards/디자인기획팀-sm.svg?inline';
|
||||
import solutionLeader from '../assets/cards/솔루션통합팀.svg?inline';
|
||||
import solutionEmployee from '../assets/cards/솔루션통합팀-sm.svg?inline';
|
||||
import talentLeader from '../assets/cards/인재성장팀.svg?inline';
|
||||
import talentEmployee from '../assets/cards/인재성장팀-sm.svg?inline';
|
||||
import systemLeader from '../assets/cards/통합시스템 디비전.svg?inline';
|
||||
import systemEmployee from '../assets/cards/통합시스템 디비전-sm.svg?inline';
|
||||
|
||||
export type DepartmentCardSize = 'leader' | 'employee';
|
||||
export interface DepartmentCardAsset {
|
||||
departmentId: string;
|
||||
label: string;
|
||||
size: DepartmentCardSize;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Presentation-only mapping. The ten supplied SVGs are copied byte-for-byte;
|
||||
// business-management uses vectors generated from the supplied PNG's colors.
|
||||
// Vite embeds both in ESM/IIFE bundles, including file:// consumers.
|
||||
// User-approved alias: 솔루션통합팀 assets -> existing solution-sales data.
|
||||
const departments = new Map([
|
||||
['business-management', { label: '사업관리팀', leader: managementLeader, employee: managementEmployee }],
|
||||
['business-planning', { label: '경영기획팀', leader: businessLeader, employee: businessEmployee }],
|
||||
['design-planning', { label: '디자인기획팀', leader: designLeader, employee: designEmployee }],
|
||||
['solution-sales', { label: '솔루션통합팀', leader: solutionLeader, employee: solutionEmployee }],
|
||||
['talent-growth', { label: '인재성장팀', leader: talentLeader, employee: talentEmployee }],
|
||||
['integrated-system', { label: '통합시스템 Div', leader: systemLeader, employee: systemEmployee }],
|
||||
]);
|
||||
|
||||
/** Resolve actual ancestry; do not infer a department from ID spelling/colors.
|
||||
* Unknown organizations and departments without supplied assets stay unchanged. */
|
||||
export function getDepartmentCardAsset(
|
||||
departmentId: string, size: DepartmentCardSize,
|
||||
organizations: readonly { id: string; parentId: string | null }[] = [],
|
||||
): DepartmentCardAsset | undefined {
|
||||
const visited = new Set<string>();
|
||||
let current: string | null = departmentId;
|
||||
while (current && !visited.has(current)) {
|
||||
visited.add(current);
|
||||
const entry = departments.get(current);
|
||||
if (entry) return { departmentId: current, label: entry.label, size, url: entry[size],
|
||||
width: size === 'leader' ? 146 : 108, height: size === 'leader' ? 68 : 52 };
|
||||
current = organizations.find(item => item.id === current)?.parentId ?? null;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { RenderContext } from './render';
|
||||
import { applyOrganizationStyle, organizationStylePresets } from './style-presets';
|
||||
import { createEmployeeCard } from './employee-card';
|
||||
import { contrastRatio, evaluateGradientContrast, parseOpaqueColor, textContrastThreshold } from './contrast';
|
||||
import { getDepartmentCardAsset } from './department-card-assets';
|
||||
import { representativeCardAsset } from './representative-card-asset';
|
||||
|
||||
export function createDepartmentColors(document: Document, context: RenderContext, signal: AbortSignal): HTMLElement {
|
||||
const section = document.createElement('section'); section.className = 'hm-orgchart-department-colors';
|
||||
const note = document.createElement('p');
|
||||
note.textContent = 'OrgChart 독립 디자인. 대표자 전용 SVG 146×74, 부서 조직장 SVG 146×68, 일반 직원 SVG 108×52를 구분합니다. 5개 조직은 전달된 원본 SVG이며 사업관리는 전달 PNG의 색상·투명도를 추출해 제작한 SVG입니다. 솔루션통합팀 자산은 기존 솔루션영업에 매핑합니다. 아래 색상 견본은 기존 헤더·기본 카드 CSS 값이며 SVG 픽셀 색상이 아닙니다. 공통 HM Theme Token을 사용하지 않습니다. 직원과 인원 통계는 가상 데이터입니다.';
|
||||
section.append(note);
|
||||
const contrastNote = document.createElement('p'); contrastNote.className = 'hm-orgchart-contrast-note';
|
||||
contrastNote.textContent = '대비는 불투명 sRGB 180deg gradient와 실제 텍스트 Range의 줄별 영역을 사용한 계산 MODEL입니다(픽셀 실측 아님). 전체 stop 최소값은 실제 글자 영역 판정과 별도로 표시합니다. 큰 Story 제목의 PASS는 Full Composition 15px 일반 텍스트의 PASS를 뜻하지 않습니다. 후보는 미승인·운영 미적용이며 브랜드 gradient를 유지하더라도 텍스트/배경 변경 승인이 필요합니다.';
|
||||
section.append(contrastNote);
|
||||
const representative = context.data.employees.find(item => context.topLeaderIds.has(item.id));
|
||||
if (representative) {
|
||||
const preview = document.createElement('section'); preview.className = 'hm-orgchart-representative-preview';
|
||||
const title = document.createElement('h2'); title.textContent = '대표자 전용 카드 · 146×74';
|
||||
const caption = document.createElement('p'); caption.textContent = '최상단 단독 리더 전용 SVG이며 부서 조직장(146×68)과 별도 종류입니다.';
|
||||
preview.append(title, createEmployeeCard(document, representative, { kind: 'topLeader', cardAsset: representativeCardAsset,
|
||||
affiliationName: context.data.affiliations.find(item => item.id === representative.affiliationId)?.name }, signal), caption);
|
||||
section.append(preview);
|
||||
}
|
||||
for (let index = 0; index < organizationStylePresets.length; index++) {
|
||||
const preset = organizationStylePresets[index]!;
|
||||
const row = document.createElement('section'); row.className = 'hm-orgchart-color-preview';
|
||||
applyOrganizationStyle(row, index);
|
||||
const heading = document.createElement('h2'); heading.className = 'hm-orgchart-color-department-name'; heading.textContent = preset.label;
|
||||
heading.dataset.contrastTarget = 'current';
|
||||
const source = document.createElement('p'); source.textContent = `${preset.source} · ${preset.observedOn} · ${preset.version} · ${preset.selector}`;
|
||||
const legend = document.createElement('dl'); legend.className = 'hm-orgchart-color-legend';
|
||||
const entries = [
|
||||
['카드 배경', 'card-background', 'current-card-background', 'background-color', 'DESIGN_ADAPTATION: OrgChart 기본값'],
|
||||
['직원 텍스트', 'card-color', 'current-card-color', 'color', 'DESIGN_ADAPTATION: OrgChart 기본값'],
|
||||
['부서명 텍스트', 'department-color', 'current-department-color', 'color', `WORKDESK_COMPUTED: ${preset.departmentColor}`],
|
||||
['테두리', 'border', 'current-border', 'background-color', `DESIGN_ADAPTATION: ${preset.borderColor}, 헤더 시작색 재사용; 원본 카드 테두리 NOT VERIFIED`],
|
||||
['Gradient', 'gradient', 'current-gradient', 'background-image', `WORKDESK_COMPUTED: ${preset.backgroundImage}`],
|
||||
];
|
||||
for (const [label, suffix, resolved, property, evidence] of entries) {
|
||||
const term = document.createElement('dt'); term.textContent = label!;
|
||||
const detail = document.createElement('dd');
|
||||
const swatch = document.createElement('span'); swatch.className = 'hm-orgchart-color-swatch';
|
||||
swatch.style.setProperty(property!, `var(--hm-orgchart-${resolved})`);
|
||||
if (property === 'color') {
|
||||
swatch.textContent = 'Aa';
|
||||
swatch.style.background = suffix === 'department-color' ? 'var(--hm-orgchart-current-gradient)' : 'var(--hm-orgchart-current-card-background)';
|
||||
}
|
||||
swatch.dataset.colorVariable = `${preset.variablePrefix}-${suffix}`; swatch.dataset.cssProperty = property;
|
||||
const variable = document.createElement('code'); variable.textContent = swatch.dataset.colorVariable;
|
||||
const value = document.createElement('span'); value.className = 'hm-orgchart-color-value';
|
||||
const provenance = document.createElement('span'); provenance.textContent = `${evidence} · OrgChart 전용 변수`;
|
||||
detail.append(swatch, variable, value, provenance); legend.append(term, detail);
|
||||
}
|
||||
const cards = document.createElement('div'); cards.className = 'hm-orgchart-preview-grid';
|
||||
const organization = context.data.organizations.find(item => item.id === preset.organizationId);
|
||||
const descendants = new Set<string>([preset.organizationId]);
|
||||
for (const item of context.orderedOrganizations) if (item.parentId && descendants.has(item.parentId)) descendants.add(item.id);
|
||||
const leader = context.data.employees.find(item => item.id === organization?.leaderId);
|
||||
const leaderIds = new Set(context.data.organizations.map(item => item.leaderId).filter(Boolean));
|
||||
const employee = context.data.employees.find(item => descendants.has(item.organizationId) && !leaderIds.has(item.id));
|
||||
for (const [person, size] of [[leader, 'leader'], [employee, 'employee']] as const) {
|
||||
if (!person) continue;
|
||||
const asset = getDepartmentCardAsset(preset.organizationId, size);
|
||||
const specimen = document.createElement('div'); specimen.className = 'hm-orgchart-card-specimen';
|
||||
const caption = document.createElement('p'); caption.className = 'hm-orgchart-card-caption';
|
||||
caption.textContent = asset ? `${asset.label} · ${size === 'leader' ? '조직장' : '일반 직원'} ${asset.width}×${asset.height} · ${asset.departmentId === 'business-management' ? 'PNG 색상 추출 SVG' : '원본 SVG'}` : `${preset.label} · ${size === 'leader' ? '조직장' : '일반 직원'} · 기존 기본 카드(전달 자산 없음)`;
|
||||
specimen.append(createEmployeeCard(document, person, { kind: size, cardAsset: asset, affiliationName: context.data.affiliations.find(item => item.id === person.affiliationId)?.name }, signal), caption);
|
||||
cards.append(specimen);
|
||||
}
|
||||
const contrast = document.createElement('section'); contrast.className = 'hm-orgchart-contrast-comparison';
|
||||
const comparisonTitle = document.createElement('h3'); comparisonTitle.textContent = '텍스트 대비 MODEL · 현재 / 일반 제목 / 미적용 후보';
|
||||
const normal = document.createElement('div'); normal.className = 'hm-orgchart-contrast-normal'; normal.dataset.contrastTarget = 'full-normal'; normal.textContent = preset.label;
|
||||
const candidate = document.createElement('div'); candidate.className = 'hm-orgchart-contrast-candidate'; candidate.dataset.contrastTarget = 'candidate'; candidate.dataset.designStatus = 'UNAPPROVED_NOT_APPLIED'; candidate.textContent = preset.label;
|
||||
candidate.style.color = 'var(--hm-orgchart-contrast-candidate-color, #10243b)';
|
||||
const chip = document.createElement('div'); chip.className = 'hm-orgchart-contrast-chip'; chip.dataset.designStatus = 'UNAPPROVED_NOT_APPLIED';
|
||||
const chipLabel = document.createElement('span'); chipLabel.className = 'hm-orgchart-contrast-chip-label'; chipLabel.dataset.contrastTarget = 'candidate-chip'; chipLabel.textContent = preset.label;
|
||||
chipLabel.style.color = 'var(--hm-orgchart-contrast-candidate-color, #10243b)'; chipLabel.style.backgroundColor = 'var(--hm-orgchart-contrast-candidate-chip-background, #ffffff)'; chip.append(chipLabel);
|
||||
const results = document.createElement('dl'); results.className = 'hm-orgchart-contrast-results';
|
||||
for (const [key, label] of [['current', '현재 Story 제목: 실제 크기 기준'], ['full-normal', 'Full 제목 동등 예제: 15px bold 일반 텍스트'], ['candidate', '후보 #10243b: 미승인·미적용'], ['candidate-chip', '후보 white label + #10243b: 미승인·미적용']]) {
|
||||
const term = document.createElement('dt'); term.textContent = label;
|
||||
const result = document.createElement('dd'); result.className = 'hm-orgchart-contrast-result'; result.dataset.contrastFor = key; result.dataset.contrastStatus = 'NOT VERIFIED'; result.textContent = 'NOT VERIFIED · CSS/텍스트 영역 측정 대기'; results.append(term, result);
|
||||
}
|
||||
const stops = document.createElement('p'); stops.className = 'hm-orgchart-contrast-stops'; stops.textContent = '전체 gradient stop 및 흑색 대안 MODEL: NOT VERIFIED';
|
||||
contrast.append(comparisonTitle, normal, candidate, chip, results, stops);
|
||||
row.append(heading, source, legend, cards, contrast); section.append(row);
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
export function updateDepartmentColorValues(root: HTMLElement): void {
|
||||
const window = root.ownerDocument.defaultView;
|
||||
if (!window) return;
|
||||
for (const swatch of root.querySelectorAll<HTMLElement>('[data-color-variable]')) {
|
||||
const raw = window.getComputedStyle(swatch).getPropertyValue(swatch.dataset.cssProperty!);
|
||||
const value = swatch.parentElement!.querySelector('.hm-orgchart-color-value')!;
|
||||
const text = raw.replace(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/g, (_, r: string, g: string, b: string) => `#${[r, g, b].map(part => Number(part).toString(16).padStart(2, '0')).join('')}`);
|
||||
if (value.textContent !== text) value.textContent = text;
|
||||
}
|
||||
updateDepartmentContrastValues(root);
|
||||
}
|
||||
|
||||
/** Runtime DOM geometry feeds the pure model. Unknown compositing stays NV;
|
||||
* this deliberately does not claim to inspect browser-rendered pixel colors. */
|
||||
export function updateDepartmentContrastValues(root: HTMLElement): void {
|
||||
const window = root.ownerDocument.defaultView;
|
||||
if (!window) return;
|
||||
for (const row of root.querySelectorAll<HTMLElement>('.hm-orgchart-color-preview')) {
|
||||
for (const target of row.querySelectorAll<HTMLElement>('[data-contrast-target]')) {
|
||||
const output = row.querySelector<HTMLElement>(`[data-contrast-for="${target.dataset.contrastTarget}"]`);
|
||||
if (!output) continue;
|
||||
const measurement = measureTextContrast(target);
|
||||
output.dataset.contrastStatus = measurement.status;
|
||||
output.dataset.contrastModel = measurement.model;
|
||||
if (output.textContent !== measurement.text) output.textContent = measurement.text;
|
||||
for (const key of ['contrastRatio', 'contrastThreshold', 'contrastFontSize', 'contrastFontWeight'] as const) delete output.dataset[key];
|
||||
if (measurement.ratio !== undefined) output.dataset.contrastRatio = String(measurement.ratio);
|
||||
if (measurement.threshold !== undefined) output.dataset.contrastThreshold = String(measurement.threshold);
|
||||
if (measurement.fontSize !== undefined) output.dataset.contrastFontSize = String(measurement.fontSize);
|
||||
if (measurement.fontWeight !== undefined) output.dataset.contrastFontWeight = String(measurement.fontWeight);
|
||||
}
|
||||
const heading = row.querySelector<HTMLElement>('[data-contrast-target="current"]')!;
|
||||
const style = window.getComputedStyle(heading), height = heading.getBoundingClientRect().height;
|
||||
const current = evaluateGradientContrast({ foreground: style.color, backgroundImage: style.backgroundImage, ranges: [{ top: 0, bottom: height }], backgroundHeight: height });
|
||||
const black = evaluateGradientContrast({ foreground: '#000000', backgroundImage: style.backgroundImage, ranges: [{ top: 0, bottom: height }], backgroundHeight: height });
|
||||
const stops = row.querySelector<HTMLElement>('.hm-orgchart-contrast-stops')!;
|
||||
stops.dataset.contrastModel = current.status;
|
||||
const stopText = current.status === 'MODEL' && black.status === 'MODEL'
|
||||
? `전체 stop 최소 ${current.stopMinimum!.toFixed(2)}:1 · 전체 gradient 257지점 MODEL 최소 ${current.fullGradientMinimum!.toFixed(2)}:1 (실제 텍스트 영역 아님; 일괄 판정하지 않음). 미적용 #000 대안 전체 MODEL 최소 ${black.fullGradientMinimum!.toFixed(3)}:1. 디자인기획 기본 gradient의 #000 대안은 약 4.607:1이며 승인 전 미적용.`
|
||||
: `전체 gradient MODEL NOT VERIFIED · ${current.reason ?? black.reason}`;
|
||||
if (stops.textContent !== stopText) stops.textContent = stopText;
|
||||
if (current.stopMinimum !== undefined) stops.dataset.contrastStopMinimum = String(current.stopMinimum); else delete stops.dataset.contrastStopMinimum;
|
||||
if (black.fullGradientMinimum !== undefined) stops.dataset.contrastBlackMinimum = String(black.fullGradientMinimum); else delete stops.dataset.contrastBlackMinimum;
|
||||
}
|
||||
}
|
||||
|
||||
interface TextMeasurement { status: 'PASS' | 'REVIEW' | 'NOT VERIFIED'; model: 'MODEL' | 'NOT VERIFIED'; text: string; ratio?: number; threshold?: number; fontSize?: number; fontWeight?: number }
|
||||
function measureTextContrast(target: HTMLElement): TextMeasurement {
|
||||
const window = target.ownerDocument.defaultView!;
|
||||
const unknown = (reason: string): TextMeasurement => ({ status: 'NOT VERIFIED', model: 'NOT VERIFIED', text: `NOT VERIFIED · ${reason}` });
|
||||
for (let element: HTMLElement | null = target; element;) {
|
||||
const style = window.getComputedStyle(element);
|
||||
if (Number(style.opacity) !== 1 || style.filter !== 'none' || style.transform !== 'none' || style.mixBlendMode !== 'normal' || (style.backdropFilter && style.backdropFilter !== 'none')) return unknown('opacity/filter/transform/blend의 색상 합성 지원 안 함');
|
||||
const tree = element.getRootNode();
|
||||
element = element.parentElement ?? ((tree as ShadowRoot).host as HTMLElement | undefined) ?? null;
|
||||
}
|
||||
const style = window.getComputedStyle(target), box = target.getBoundingClientRect();
|
||||
if (style.textShadow !== 'none') return unknown('text-shadow 지원 안 함');
|
||||
const fontSize = Number.parseFloat(style.fontSize), fontWeight = style.fontWeight === 'bold' ? 700 : style.fontWeight === 'normal' ? 400 : Number(style.fontWeight);
|
||||
if (!Number.isFinite(fontSize) || !Number.isFinite(fontWeight)) return unknown('텍스트 크기/굵기 확인 불가');
|
||||
const threshold = textContrastThreshold(fontSize, fontWeight);
|
||||
const ranges: { top: number; bottom: number }[] = [];
|
||||
const walker = target.ownerDocument.createTreeWalker(target, 4 /* SHOW_TEXT */);
|
||||
const lineRects: DOMRect[] = [];
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (!node.textContent?.trim()) continue;
|
||||
const range = target.ownerDocument.createRange(); range.selectNodeContents(node);
|
||||
lineRects.push(...Array.from(range.getClientRects()).filter(rect => rect.width > 0 && rect.height > 0));
|
||||
}
|
||||
if (lineRects.length === 0 || box.height <= 0) return unknown('실제 텍스트 Range 없음');
|
||||
let ratio: number | undefined;
|
||||
if (style.backgroundImage === 'none') {
|
||||
const foreground = parseOpaqueColor(style.color), background = parseOpaqueColor(style.backgroundColor);
|
||||
if (!foreground || !background) return unknown('불투명 solid 배경/텍스트만 지원');
|
||||
if (lineRects.some(rect => rect.left < box.left || rect.right > box.right || rect.top < box.top || rect.bottom > box.bottom)) return unknown('텍스트가 solid 배경 영역 밖에 있음');
|
||||
ratio = contrastRatio(foreground, background);
|
||||
} else {
|
||||
if (!['auto', 'auto auto'].includes(style.backgroundSize) || style.backgroundPosition !== '0% 0%' || !['border-box', 'padding-box', 'content-box'].includes(style.backgroundOrigin)) return unknown('background size/position/origin 지원 안 함');
|
||||
const borderTop = Number.parseFloat(style.borderTopWidth), borderBottom = Number.parseFloat(style.borderBottomWidth);
|
||||
const borderLeft = Number.parseFloat(style.borderLeftWidth), borderRight = Number.parseFloat(style.borderRightWidth);
|
||||
const content = style.backgroundOrigin === 'content-box', includeBorder = style.backgroundOrigin === 'border-box';
|
||||
const top = box.top + (includeBorder ? 0 : borderTop) + (content ? Number.parseFloat(style.paddingTop) : 0);
|
||||
const bottom = box.bottom - (includeBorder ? 0 : borderBottom) - (content ? Number.parseFloat(style.paddingBottom) : 0);
|
||||
const left = box.left + (includeBorder ? 0 : borderLeft) + (content ? Number.parseFloat(style.paddingLeft) : 0);
|
||||
const right = box.right - (includeBorder ? 0 : borderRight) - (content ? Number.parseFloat(style.paddingRight) : 0);
|
||||
if (lineRects.some(rect => rect.left < left || rect.right > right)) return unknown('텍스트가 gradient painting box 밖에 있음');
|
||||
for (const rect of lineRects) ranges.push({ top: rect.top - top, bottom: rect.bottom - top });
|
||||
const model = evaluateGradientContrast({ foreground: style.color, backgroundImage: style.backgroundImage, ranges, backgroundHeight: bottom - top });
|
||||
if (model.status !== 'MODEL') return unknown(model.reason!);
|
||||
ratio = model.textRegionMinimum!;
|
||||
}
|
||||
const status = ratio >= threshold ? 'PASS' : 'REVIEW';
|
||||
return { status, model: 'MODEL', ratio, threshold, fontSize, fontWeight, text: `${status} · 실제 Range 영역 MODEL 최소 ${ratio.toFixed(2)}:1 / 기준 ${threshold}:1 · ${fontSize}px / ${fontWeight}. 픽셀 실측 아님; 현재 예제 영역에만 해당.` };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Employee } from '../data/index.js';
|
||||
import personPlaceholder from '../assets/person-placeholder.svg?inline';
|
||||
import { createAffiliationMark } from './affiliation-mark.js';
|
||||
import type { DepartmentCardAsset } from './department-card-assets.js';
|
||||
import type { RepresentativeCardAsset } from './representative-card-asset.js';
|
||||
import { observePersonNameOverflow } from './person-name-overflow.js';
|
||||
|
||||
const statusNames = { active: '재직', dispatched: '파견', onLeave: '휴직' } as const;
|
||||
|
||||
export interface EmployeeCardOptions {
|
||||
kind: 'topLeader' | 'leader' | 'employee';
|
||||
affiliationName?: string;
|
||||
cardAsset?: DepartmentCardAsset | RepresentativeCardAsset;
|
||||
/** Full Composition presentation only; the employee dataset is untouched. */
|
||||
workdeskPresentation?: boolean;
|
||||
/** Compact read-only status groups already convey this label in their header. */
|
||||
hideStatusLabel?: boolean;
|
||||
}
|
||||
|
||||
function text(document: Document, tag: string, className: string, value: string): HTMLElement {
|
||||
const element = document.createElement(tag);
|
||||
element.className = className;
|
||||
element.textContent = value;
|
||||
return element;
|
||||
}
|
||||
|
||||
/** Content renderer shared by the hierarchy and supplementary status groups. */
|
||||
export function createEmployeeCard(
|
||||
document: Document, employee: Employee, options: EmployeeCardOptions, signal: AbortSignal,
|
||||
): HTMLElement {
|
||||
const leader = options.kind !== 'employee';
|
||||
const representative = options.kind === 'topLeader' || options.cardAsset?.size === 'representative';
|
||||
const compactStatus = Boolean(options.workdeskPresentation && options.hideStatusLabel && !options.cardAsset);
|
||||
const styledCard = Boolean(options.cardAsset) || compactStatus;
|
||||
const presentationName = options.workdeskPresentation || representative;
|
||||
const displayName = presentationName ? '홍길동' : employee.name;
|
||||
const displayedName = presentationName ? '홍 길 동' : displayName;
|
||||
const card = document.createElement('div');
|
||||
card.className = `hm-orgchart-employee${leader ? ' hm-orgchart-leader' : ''}`;
|
||||
card.dataset.employeeId = employee.id;
|
||||
card.dataset.cardKind = options.kind;
|
||||
card.dataset.workStatus = employee.workStatus;
|
||||
if (options.workdeskPresentation) card.dataset.workdeskPresentation = 'true';
|
||||
if (compactStatus) card.dataset.cardLayout = 'compact';
|
||||
if (options.cardAsset) {
|
||||
const asset = options.cardAsset;
|
||||
card.dataset.cardAsset = 'departmentId' in asset ? asset.departmentId : 'representative';
|
||||
card.dataset.cardAssetSize = asset.size;
|
||||
const background = document.createElement('img');
|
||||
background.className = 'hm-orgchart-card-background';
|
||||
background.alt = '';
|
||||
background.setAttribute('aria-hidden', 'true');
|
||||
background.width = asset.width;
|
||||
background.height = asset.height;
|
||||
background.src = asset.url;
|
||||
card.append(background);
|
||||
}
|
||||
|
||||
// One neutral icon is shared by every card type and both runtime adapters.
|
||||
// The approved rectangular photo slots keep their existing dimensions.
|
||||
const avatar = document.createElement('span');
|
||||
avatar.className = 'hm-orgchart-avatar';
|
||||
avatar.setAttribute('aria-hidden', 'true');
|
||||
const image = document.createElement('img');
|
||||
image.alt = '';
|
||||
image.loading = 'lazy';
|
||||
image.decoding = 'async';
|
||||
image.referrerPolicy = 'no-referrer';
|
||||
const showPlaceholder = () => {
|
||||
card.dataset.photoState = 'placeholder';
|
||||
image.dataset.photoPlaceholder = 'true';
|
||||
image.src = personPlaceholder;
|
||||
};
|
||||
if (employee.profileImage) {
|
||||
card.dataset.photoState = 'image';
|
||||
image.addEventListener('error', showPlaceholder, { once: true, signal });
|
||||
// Images have already passed the dataset URL validation. No HTML parsing.
|
||||
image.src = employee.profileImage;
|
||||
} else showPlaceholder();
|
||||
avatar.append(image);
|
||||
|
||||
const details = document.createElement('div');
|
||||
details.className = 'hm-orgchart-employee-details';
|
||||
const displayedPosition = representative ? '대표이사' : employee.position.replace(/\s+/gu, '');
|
||||
const position = text(document, 'span', 'hm-orgchart-person-role', displayedPosition);
|
||||
const name = text(document, 'strong', 'hm-orgchart-person-name', displayedName);
|
||||
if (presentationName) {
|
||||
position.setAttribute('aria-label', representative ? displayedPosition : employee.position);
|
||||
name.setAttribute('aria-label', displayName);
|
||||
}
|
||||
const inlineRoleBadge = styledCard && !representative;
|
||||
const positionRow = inlineRoleBadge ? document.createElement('div') : null;
|
||||
if (positionRow) {
|
||||
positionRow.className = 'hm-orgchart-position-row';
|
||||
positionRow.append(position);
|
||||
}
|
||||
if (styledCard) {
|
||||
const personText = document.createElement('div'); personText.className = 'hm-orgchart-person-text';
|
||||
personText.append(positionRow ?? position, name); details.append(personText);
|
||||
observePersonNameOverflow(card, name, signal);
|
||||
} else details.append(position, name);
|
||||
const attributes = document.createElement('div');
|
||||
attributes.className = 'hm-orgchart-person-attributes';
|
||||
// Representative cards show only position/name in every consumer. Raw employee
|
||||
// roles remain available to dialogs/statistics; other cards keep their policy.
|
||||
const hideExecutiveBadge = options.workdeskPresentation && employee.jobTitle?.trim() === '경영진';
|
||||
if (!representative && !hideExecutiveBadge && (employee.jobTitle || (leader && !options.cardAsset))) {
|
||||
const rawBadge = employee.jobTitle || '조직장';
|
||||
const badge = text(document, 'span', 'hm-orgchart-job-title', options.workdeskPresentation ? rawBadge.replace(/\s+/gu, '') : rawBadge);
|
||||
if (options.workdeskPresentation) badge.setAttribute('aria-label', employee.jobTitle || '조직장');
|
||||
badge.title = badge.textContent!;
|
||||
(positionRow ?? attributes).append(badge);
|
||||
}
|
||||
if (!representative && !options.hideStatusLabel) attributes.append(text(document, 'span', 'hm-orgchart-status-label', statusNames[employee.workStatus]));
|
||||
if (styledCard) {
|
||||
card.append(avatar, details);
|
||||
if (attributes.childElementCount) card.append(attributes);
|
||||
} else {
|
||||
if (attributes.childElementCount) details.append(attributes);
|
||||
card.append(avatar, details);
|
||||
}
|
||||
|
||||
if (options.affiliationName) {
|
||||
const affiliation = createAffiliationMark(document, employee.affiliationId, options.affiliationName, signal);
|
||||
card.dataset.hasAffiliation = 'true';
|
||||
card.append(affiliation);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Employee } from '../data/index.js';
|
||||
import type { OrgChartEmployeeClickContext } from './types.js';
|
||||
|
||||
const statusNames = { active: '재직', dispatched: '파견', onLeave: '휴직' } as const;
|
||||
|
||||
export interface EmployeeInfoDialog {
|
||||
show(employee: Employee, context: OrgChartEmployeeClickContext): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/** The native modal lives in the unscaled shell and enters the browser's top
|
||||
* layer. It keeps the Core shared by HTML and React, including focus handling. */
|
||||
export function createEmployeeInfoDialog(
|
||||
shell: HTMLElement, signal: AbortSignal, prefix: string, readOnly: boolean,
|
||||
): EmployeeInfoDialog {
|
||||
const document = shell.ownerDocument;
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.className = 'hm-orgchart-employee-dialog';
|
||||
dialog.dataset.component = 'EmployeeInfoDialog';
|
||||
dialog.setAttribute('aria-modal', 'true');
|
||||
const header = document.createElement('div');
|
||||
header.className = 'hm-orgchart-employee-dialog-header';
|
||||
const title = document.createElement('h2');
|
||||
title.id = `${prefix}-employee-dialog-title`;
|
||||
title.textContent = '직원 정보';
|
||||
dialog.setAttribute('aria-labelledby', title.id);
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.type = 'button';
|
||||
closeButton.className = 'hm-orgchart-employee-dialog-close';
|
||||
closeButton.textContent = '닫기';
|
||||
closeButton.setAttribute('aria-label', '직원 정보 닫기');
|
||||
const fields = document.createElement('dl');
|
||||
fields.className = 'hm-orgchart-employee-dialog-fields';
|
||||
header.append(title, closeButton);
|
||||
dialog.append(header, fields);
|
||||
shell.append(dialog);
|
||||
let trigger: HTMLElement | undefined;
|
||||
|
||||
function restoreFocus(): void {
|
||||
const previous = trigger;
|
||||
// close() restores synchronously, while the native close event is queued.
|
||||
// Only a real activation owns a return target: a second close notification
|
||||
// (or routine render cleanup before the dialog ever opens) must not focus
|
||||
// the shell and displace the card's restored focus.
|
||||
if (!previous) return;
|
||||
trigger = undefined;
|
||||
if (signal.aborted || !shell.isConnected) return;
|
||||
(previous?.isConnected ? previous : shell).focus({ preventScroll: true });
|
||||
}
|
||||
function close(): void {
|
||||
if (dialog.open) dialog.close();
|
||||
restoreFocus();
|
||||
}
|
||||
closeButton.addEventListener('click', close, { signal });
|
||||
dialog.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}, { signal });
|
||||
dialog.addEventListener('close', () => { if (!dialog.open) restoreFocus(); }, { signal });
|
||||
dialog.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Tab') return;
|
||||
const focusable = [...dialog.querySelectorAll<HTMLElement>('button:not(:disabled), a[href], [tabindex="0"]')];
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = (dialog.getRootNode() as ShadowRoot).activeElement;
|
||||
if ((event.shiftKey && active === first) || (!event.shiftKey && active === last) || !dialog.contains(active)) {
|
||||
event.preventDefault();
|
||||
(event.shiftKey ? last : first)?.focus();
|
||||
}
|
||||
}, { signal });
|
||||
signal.addEventListener('abort', () => {
|
||||
trigger = undefined;
|
||||
if (dialog.open) dialog.close();
|
||||
dialog.remove();
|
||||
}, { once: true });
|
||||
|
||||
return {
|
||||
close,
|
||||
show(employee, context) {
|
||||
if (signal.aborted) return;
|
||||
// Never synthesize contact details, dates or other personal information.
|
||||
const entries: [string, string | null | undefined][] = [
|
||||
['이름', readOnly ? context.trigger.querySelector('.hm-orgchart-person-name')?.textContent ?? employee.name : employee.name],
|
||||
['직위', employee.position],
|
||||
['소속', context.organization?.name],
|
||||
['직책', employee.jobTitle],
|
||||
['관계사', context.affiliation?.name],
|
||||
['근무 상태', statusNames[employee.workStatus]],
|
||||
];
|
||||
fields.replaceChildren();
|
||||
for (const [label, value] of entries) {
|
||||
if (!value) continue;
|
||||
const term = document.createElement('dt');
|
||||
term.textContent = label;
|
||||
const detail = document.createElement('dd');
|
||||
detail.textContent = value;
|
||||
fields.append(term, detail);
|
||||
}
|
||||
dialog.dataset.employeeId = employee.id;
|
||||
trigger = context.trigger;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
closeButton.focus({ preventScroll: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type OrgChart from 'orgchart';
|
||||
import type { EngineNode } from 'orgchart';
|
||||
|
||||
export type RenderEngineNode = EngineNode;
|
||||
export interface EngineAdapter {
|
||||
update(data: RenderEngineNode[]): void;
|
||||
toggle(node: HTMLElement): boolean | undefined;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/** Importing the public facade never evaluates the upstream browser-only module. */
|
||||
export async function loadEngine() {
|
||||
const { default: Upstream } = await import('orgchart');
|
||||
// 6.0.0 has no destroy(). Its init observer is local to triggerInitEvent.
|
||||
// Override that narrow hook on our subclass to keep the resource disposable.
|
||||
return class ManagedOrgChart extends Upstream {
|
||||
declare initObserver?: MutationObserver;
|
||||
override init(options?: Parameters<OrgChart['init']>[0]): OrgChart {
|
||||
try {
|
||||
return super.init(options);
|
||||
} catch (error) {
|
||||
this.disconnectInitObserver();
|
||||
this.chart?.remove();
|
||||
this.chart = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
override triggerInitEvent(): void {
|
||||
this.disconnectInitObserver();
|
||||
const Observer = this.chartContainer?.ownerDocument.defaultView?.MutationObserver;
|
||||
if (!Observer || !this.chartContainer) return;
|
||||
const observer = new Observer(() => {
|
||||
observer.disconnect();
|
||||
if (this.initObserver === observer) this.initObserver = undefined;
|
||||
if (this.chart) this.handleCompactNodes();
|
||||
});
|
||||
this.initObserver = observer;
|
||||
observer.observe(this.chartContainer, { childList: true });
|
||||
}
|
||||
disconnectInitObserver(): void {
|
||||
this.initObserver?.disconnect();
|
||||
this.initObserver = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type EngineConstructor = Awaited<ReturnType<typeof loadEngine>>;
|
||||
|
||||
export function createEngineAdapter(
|
||||
Engine: EngineConstructor,
|
||||
viewport: HTMLElement,
|
||||
data: RenderEngineNode[],
|
||||
decorate: (node: HTMLElement, data: RenderEngineNode, signal: AbortSignal) => void,
|
||||
): EngineAdapter {
|
||||
const Abort = viewport.ownerDocument.defaultView?.AbortController ?? AbortController;
|
||||
let generation = new Abort();
|
||||
let instances: (OrgChart & { disconnectInitObserver(): void })[] = [];
|
||||
let disposed = false;
|
||||
const forest = viewport.ownerDocument.createElement('div');
|
||||
forest.className = 'hm-orgchart-forest';
|
||||
viewport.append(forest);
|
||||
function releaseGeneration(): void {
|
||||
generation.abort();
|
||||
for (const instance of instances) {
|
||||
instance.disconnectInitObserver();
|
||||
// Keep the old chart reference until upstream feature cleanup is complete.
|
||||
instance.unbindPan(); instance.unbindZoom(); instance.detachMinimap();
|
||||
}
|
||||
}
|
||||
function disposeInstances(): void {
|
||||
releaseGeneration();
|
||||
for (const instance of instances) {
|
||||
instance.chart?.remove();
|
||||
instance.chart = null; instance.chartContainer = null;
|
||||
instance.opts = {}; instance.options = {};
|
||||
}
|
||||
instances = [];
|
||||
forest.replaceChildren();
|
||||
}
|
||||
function build(roots: RenderEngineNode[]): void {
|
||||
try {
|
||||
for (const root of roots) {
|
||||
const treeContainer = viewport.ownerDocument.createElement('div');
|
||||
treeContainer.className = 'hm-orgchart-tree';
|
||||
forest.append(treeContainer);
|
||||
const instance = new Engine(treeContainer, {
|
||||
data: root, pan: false, zoom: false, minimap: false, draggable: false, exportButton: false,
|
||||
chartClass: 'hm-orgchart-engine',
|
||||
nodeTemplate: () => '<article class="hm-orgchart-organization"></article>',
|
||||
createNode: (node, value) => decorate(node, value, generation.signal),
|
||||
});
|
||||
instances.push(instance);
|
||||
}
|
||||
} catch (error) { disposeInstances(); throw error; }
|
||||
}
|
||||
function syncButtons(): void {
|
||||
for (const instance of instances) {
|
||||
for (const node of instance.chart?.querySelectorAll<HTMLElement>('.node') ?? []) {
|
||||
const button = node.querySelector<HTMLButtonElement>('.hm-orgchart-toggle');
|
||||
if (!button) continue;
|
||||
const expanded = instance.getNodeState(node, 'children').visible && !node.closest('.hierarchy')?.classList.contains('isChildrenCollapsed');
|
||||
button.setAttribute('aria-expanded', String(expanded));
|
||||
const label = expanded ? '하위 조직 접기' : '하위 조직 펼치기';
|
||||
button.textContent = label;
|
||||
button.setAttribute('aria-label', `${node.querySelector('.hm-orgchart-organization-name')?.textContent ?? ''} ${label}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function bindGeneration(): void {
|
||||
forest.addEventListener('transitionend', syncButtons, { signal: generation.signal });
|
||||
}
|
||||
build(data);
|
||||
bindGeneration();
|
||||
return {
|
||||
update(next) {
|
||||
if (disposed) throw new Error('OrgChart engine is disposed');
|
||||
releaseGeneration();
|
||||
generation = new Abort();
|
||||
if (next.length === instances.length) {
|
||||
for (let index = 0; index < next.length; index++) instances[index].init({ data: next[index] });
|
||||
} else {
|
||||
disposeInstances();
|
||||
generation = new Abort();
|
||||
build(next);
|
||||
}
|
||||
bindGeneration();
|
||||
},
|
||||
toggle(node) {
|
||||
const instance = instances.find(candidate => candidate.chart?.contains(node));
|
||||
if (!instance || node.closest('.hierarchy')?.querySelector('.sliding')) return undefined;
|
||||
const expanded = instance.getNodeState(node, 'children').visible;
|
||||
if (expanded) instance.hideChildren(node); else instance.showChildren(node);
|
||||
syncButtons();
|
||||
return !expanded;
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
disposeInstances();
|
||||
viewport.replaceChildren();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import { calculateSummary, normalizeOrgChartDataset, validateOrgChartDataset } from '../data/index.js';
|
||||
import type { 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';
|
||||
import { createOrgChartTree } from './org-chart-tree.js';
|
||||
import { createOrgChartContent } from './org-chart-content.js';
|
||||
import { createPositionSummary } from './position-summary.js';
|
||||
import { createAffiliationSummary } from './affiliation-summary.js';
|
||||
import { createStatusSummary } from './status-summary.js';
|
||||
import { createEmployeeInfoDialog } from './employee-info-dialog.js';
|
||||
import type { EmployeeInfoDialog } from './employee-info-dialog.js';
|
||||
import type { RenderContext } from './render.js';
|
||||
import type { OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartState } from './types.js';
|
||||
|
||||
export type { OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartOrganizationLinks, OrgChartState } from './types.js';
|
||||
|
||||
const owners = new WeakMap<HTMLElement, symbol>();
|
||||
let instanceSequence = 0;
|
||||
|
||||
function freeze<T>(value: T, seen = new WeakSet<object>()): T {
|
||||
if (value && typeof value === 'object' && !seen.has(value)) {
|
||||
seen.add(value);
|
||||
for (const child of Object.values(value)) freeze(child, seen);
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function snapshot(input: unknown): unknown {
|
||||
// JSON objects are captured before any async boundary. A failure is controlled.
|
||||
return freeze(structuredClone(input));
|
||||
}
|
||||
|
||||
/** Framework-independent facade; construction has no DOM or request side effects. */
|
||||
export function createOrgChart(options: OrgChartOptions): OrgChartHandle {
|
||||
return createOrgChartView(options, 'composition');
|
||||
}
|
||||
|
||||
export type CoreView = 'composition' | 'tree' | 'positionSummary' | 'affiliationSummary' | 'statusSummary';
|
||||
|
||||
/** One source/validation/lifecycle controller for full and standalone views.
|
||||
* Summary-only views never load the engine or require its stylesheet. */
|
||||
export function createOrgChartView(options: OrgChartOptions, view: CoreView): OrgChartHandle {
|
||||
const needsEngine = view === 'composition' || view === 'tree';
|
||||
if (!options.dataSource || typeof options.dataSource.load !== 'function') throw new TypeError('An OrgChartDataSource is required');
|
||||
if (!options.stylesheetUrls?.core || (needsEngine && !options.stylesheetUrls.engine)) throw new TypeError('Provide the required OrgChart stylesheet URLs');
|
||||
const stylesheetUrls = { ...options.stylesheetUrls };
|
||||
if (options.displayMode !== undefined && options.displayMode !== 'interactive' && options.displayMode !== 'readOnly') throw new TypeError('displayMode must be "interactive" or "readOnly"');
|
||||
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;
|
||||
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');
|
||||
const href = value.trim();
|
||||
if (!href) continue;
|
||||
let url: URL;
|
||||
try { url = new URL(href, 'https://orgchart.invalid/'); }
|
||||
catch { throw new TypeError('organizationLinks must contain valid HTTP(S) or relative URLs'); }
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new TypeError('organizationLinks must contain valid HTTP(S) or relative URLs');
|
||||
organizationLinks[id] = href;
|
||||
}
|
||||
let preferredContainer = options.container;
|
||||
let source: OrgChartDataSource | undefined = options.dataSource;
|
||||
let policy = options.aggregationPolicy ? freeze(structuredClone(options.aggregationPolicy)) : undefined;
|
||||
let state: OrgChartState = freeze({ status: 'idle' });
|
||||
let destroyed = false;
|
||||
let revision = 0;
|
||||
let request: AbortController | undefined;
|
||||
let container: HTMLElement | undefined;
|
||||
let root: HTMLElement | undefined;
|
||||
let shell: HTMLElement | undefined;
|
||||
let status: HTMLElement | undefined;
|
||||
let content: HTMLElement | undefined;
|
||||
let viewport: HTMLElement | undefined;
|
||||
let engine: EngineAdapter | undefined;
|
||||
let renderContext: RenderContext | undefined;
|
||||
let Engine: EngineConstructor | undefined;
|
||||
let styleReady: Promise<readonly ValidationIssue[] | undefined> | undefined;
|
||||
let cancelStyles: (() => void) | undefined;
|
||||
let contentGeneration: AbortController | undefined;
|
||||
let interactions: AbortController | undefined;
|
||||
let employeeDialog: EmployeeInfoDialog | undefined;
|
||||
const owner = Symbol('hm-orgchart-owner');
|
||||
const prefix = `hm-orgchart-${++instanceSequence}`;
|
||||
const listeners = new Set<(state: OrgChartState) => void>();
|
||||
if (options.onStateChange) listeners.add(options.onStateChange);
|
||||
|
||||
function active(version: number): boolean { return !destroyed && version === revision; }
|
||||
|
||||
function notify(next: OrgChartState): void {
|
||||
state = freeze(next);
|
||||
const notifiedState = state;
|
||||
for (const listener of [...listeners]) {
|
||||
// Reentrant update/reload/destroy may supersede this notification.
|
||||
if (destroyed || state !== notifiedState) break;
|
||||
if (!listeners.has(listener)) continue;
|
||||
try { listener(notifiedState); } catch { /* Consumer callbacks do not break Core lifecycle. */ }
|
||||
}
|
||||
}
|
||||
|
||||
function assertMounted(): void {
|
||||
if (destroyed) throw new Error('OrgChart handle is destroyed; create a new handle');
|
||||
if (!container) throw new Error('Mount OrgChart before update or reload');
|
||||
}
|
||||
|
||||
function begin(): { version: number; signal: AbortSignal } {
|
||||
request?.abort();
|
||||
const Abort = container?.ownerDocument.defaultView?.AbortController ?? AbortController;
|
||||
request = new Abort();
|
||||
const version = ++revision;
|
||||
status!.textContent = '조직도를 불러오는 중입니다.';
|
||||
shell!.setAttribute('aria-busy', 'true');
|
||||
const signal = request.signal;
|
||||
notify({ status: 'loading' });
|
||||
return { version, signal };
|
||||
}
|
||||
|
||||
function releaseView(): void {
|
||||
employeeDialog?.close();
|
||||
contentGeneration?.abort();
|
||||
contentGeneration = undefined;
|
||||
engine?.dispose();
|
||||
engine = undefined;
|
||||
renderContext = undefined;
|
||||
viewport = undefined;
|
||||
content?.replaceChildren();
|
||||
}
|
||||
|
||||
function activateEmployee(event: MouseEvent | KeyboardEvent): void {
|
||||
if (event.defaultPrevented || !renderContext || !content) return;
|
||||
const Element = content.ownerDocument.defaultView?.Element;
|
||||
if (!Element || !(event.target instanceof Element)) return;
|
||||
// Links, collapse buttons and any future nested controls own their events.
|
||||
if (event.target.closest('a, button, input, select, textarea')) return;
|
||||
const card = event.target.closest<HTMLElement>('.hm-orgchart-employee');
|
||||
if (!card || !content.contains(card)) return;
|
||||
const employee = renderContext.data.employees.find(item => item.id === card.dataset.employeeId);
|
||||
if (!employee) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
card.focus({ preventScroll: true });
|
||||
// A name tooltip can be open from pointer hover. Close it before opening
|
||||
// information UI; the existing hover/focus tooltip behavior stays intact.
|
||||
for (const tip of shell?.querySelectorAll<HTMLElement>('.hm-orgchart-person-name-tooltip') ?? []) tip.hidden = true;
|
||||
for (const name of content.querySelectorAll<HTMLElement>('[data-tooltip-open="true"]')) name.dataset.tooltipOpen = 'false';
|
||||
const context: OrgChartEmployeeClickContext = {
|
||||
organization: renderContext.data.organizations.find(item => item.id === employee.organizationId),
|
||||
affiliation: renderContext.data.affiliations.find(item => item.id === employee.affiliationId),
|
||||
trigger: card,
|
||||
originalEvent: event,
|
||||
};
|
||||
if (employeeClickCallback) {
|
||||
try { employeeClickCallback(employee, context); }
|
||||
catch { /* Consumer callbacks do not interrupt Core event/lifecycle ownership. */ }
|
||||
} else employeeDialog?.show(employee, context);
|
||||
}
|
||||
|
||||
function prepareEmployeeCards(element: HTMLElement): void {
|
||||
for (const card of element.querySelectorAll<HTMLElement>('.hm-orgchart-employee')) {
|
||||
card.tabIndex = 0;
|
||||
card.setAttribute('role', 'button');
|
||||
if (!employeeClickCallback) card.setAttribute('aria-haspopup', 'dialog');
|
||||
const name = card.querySelector('.hm-orgchart-person-name')?.textContent ?? '';
|
||||
const position = card.querySelector('.hm-orgchart-person-role')?.textContent ?? '';
|
||||
card.setAttribute('aria-label', `${name} ${position} 직원 정보 보기`);
|
||||
}
|
||||
}
|
||||
|
||||
function fail(version: number, issues: readonly ValidationIssue[]): void {
|
||||
if (!active(version)) return;
|
||||
releaseView();
|
||||
shell!.setAttribute('aria-busy', 'false');
|
||||
status!.textContent = '조직도를 표시할 수 없습니다.';
|
||||
const document = content!.ownerDocument;
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'hm-orgchart-issues';
|
||||
for (const issue of issues) {
|
||||
const row = document.createElement('li');
|
||||
row.textContent = `${issue.code} · ${issue.path}: ${issue.message}`;
|
||||
list.append(row);
|
||||
}
|
||||
content!.append(list);
|
||||
notify({ status: 'error', issues });
|
||||
}
|
||||
|
||||
async function apply(input: unknown, version: number): Promise<void> {
|
||||
if (!active(version)) return;
|
||||
let captured: unknown;
|
||||
try { captured = snapshot(input); }
|
||||
catch {
|
||||
fail(version, [{ code: 'INPUT_SNAPSHOT', path: '$', message: 'Input must be a cloneable JSON dataset' }]);
|
||||
return;
|
||||
}
|
||||
const validation = validateOrgChartDataset(captured);
|
||||
if (!validation.valid) { fail(version, validation.issues); return; }
|
||||
const data = freeze(validation.data);
|
||||
const normalized = normalizeOrgChartDataset(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, organizationLinks };
|
||||
// Composition shows the root leader as the standalone top card, so the root's direct
|
||||
// organizations become the horizontal engine roots (data and parentId stay untouched).
|
||||
// A root with other direct employees keeps its node so those employees stay visible.
|
||||
const flattenRoot = (node: typeof normalized.roots[number]) => view === 'composition' && node.organization.type === 'root' && !!node.organization.leaderId
|
||||
&& (normalized.employeesByOrganization.get(node.organization.id) ?? []).every(employee => employee.id === node.organization.leaderId);
|
||||
const flattened = normalized.roots.some(flattenRoot);
|
||||
const tree = engineTree(flattened ? normalized.roots.flatMap(node => flattenRoot(node) ? node.children : [node]) : normalized.roots, prefix, nextContext, flattened ? 1 : 0);
|
||||
const styleIssues = await styleReady;
|
||||
if (!active(version)) return;
|
||||
if (styleIssues) { fail(version, styleIssues); return; }
|
||||
const LoadedEngine = needsEngine ? Engine ?? await loadEngine() : undefined;
|
||||
// Last-started operation wins even if an upstream engine import was pending.
|
||||
if (!active(version)) return;
|
||||
if (LoadedEngine) Engine = LoadedEngine;
|
||||
const document = content!.ownerDocument;
|
||||
employeeDialog?.close();
|
||||
contentGeneration?.abort();
|
||||
const Abort = document.defaultView?.AbortController ?? AbortController;
|
||||
contentGeneration = new Abort();
|
||||
renderContext = nextContext;
|
||||
if (needsEngine && !viewport) {
|
||||
viewport = createOrgChartTree(document);
|
||||
content!.append(viewport);
|
||||
}
|
||||
const activeElement = root!.shadowRoot?.activeElement;
|
||||
if (needsEngine) {
|
||||
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));
|
||||
});
|
||||
}
|
||||
// If regeneration removed keyboard focus, offer the stable viewport next.
|
||||
const nextContent = view === 'composition' ? createOrgChartContent(document, nextContext, viewport!, contentGeneration.signal)
|
||||
: view === 'tree' ? viewport!
|
||||
: view === 'positionSummary' ? createPositionSummary(document, nextContext)
|
||||
: view === 'affiliationSummary' ? createAffiliationSummary(document, nextContext)
|
||||
: createStatusSummary(document, nextContext, contentGeneration.signal);
|
||||
prepareEmployeeCards(nextContent);
|
||||
content!.replaceChildren(nextContent);
|
||||
const restoreFocus = !!activeElement && (activeElement === viewport || !activeElement.isConnected);
|
||||
if (restoreFocus) (readOnly ? shell! : viewport ?? content!.querySelector<HTMLElement>('.hm-orgchart-summary-scroll') ?? shell!).focus({ preventScroll: true });
|
||||
shell!.setAttribute('aria-busy', 'false');
|
||||
status!.textContent = '';
|
||||
if (!active(version)) return;
|
||||
notify({ status: 'ready', dataVersion: data.metadata.dataVersion, summary, aggregation: summary.aggregation });
|
||||
}
|
||||
|
||||
async function reload(nextSource?: OrgChartDataSource): Promise<void> {
|
||||
assertMounted();
|
||||
if (nextSource) {
|
||||
if (typeof nextSource.load !== 'function') throw new TypeError('An OrgChartDataSource is required');
|
||||
source = nextSource;
|
||||
}
|
||||
const { version, signal } = begin();
|
||||
if (!active(version)) return;
|
||||
let input: unknown;
|
||||
try {
|
||||
input = await source!.load({ signal });
|
||||
} catch (error) {
|
||||
if (!active(version) || signal.aborted) return;
|
||||
const sourceCode = error && typeof error === 'object' && 'code' in error && ['SOURCE_LOAD', 'SOURCE_PARSE', 'SOURCE_MAP', 'SOURCE_URL', 'SOURCE_SNAPSHOT'].includes(String(error.code)) ? String(error.code) : 'SOURCE_LOAD';
|
||||
fail(version, [{ code: sourceCode, path: 'dataSource', message: 'Data source or chart rendering failed; verify the source and CSS configuration' }]);
|
||||
return;
|
||||
}
|
||||
try { if (active(version)) await apply(input, version); }
|
||||
catch { fail(version, [{ code: 'CORE_RENDER', path: 'core', message: 'Chart rendering failed' }]); }
|
||||
}
|
||||
|
||||
return {
|
||||
async mount(nextContainer = preferredContainer) {
|
||||
if (destroyed) throw new Error('OrgChart handle is destroyed; create a new handle');
|
||||
if (container) throw new Error('OrgChart is already mounted');
|
||||
const Element = nextContainer?.ownerDocument.defaultView?.HTMLElement;
|
||||
if (!Element || !(nextContainer instanceof Element) || !nextContainer.isConnected) throw new TypeError('Mount requires a connected HTMLElement');
|
||||
if (owners.has(nextContainer)) throw new Error('Another OrgChart instance owns this container');
|
||||
container = nextContainer;
|
||||
owners.set(container, owner);
|
||||
const document = container.ownerDocument;
|
||||
root = document.createElement('div');
|
||||
root.className = 'hm-orgchart-host';
|
||||
root.dataset.orgchartInstance = prefix;
|
||||
const shadow = root.attachShadow({ mode: 'open' });
|
||||
const styleDisposers: (() => void)[] = [];
|
||||
const styles = [...(needsEngine ? [{ key: 'engine', href: stylesheetUrls.engine }] : []), { key: 'core', href: stylesheetUrls.core }];
|
||||
styleReady = Promise.all(styles.map(({ key, href }) => new Promise<ValidationIssue | undefined>(resolve => {
|
||||
const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href;
|
||||
let settled = false;
|
||||
const finish = (issue?: ValidationIssue) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
link.removeEventListener('load', loaded); link.removeEventListener('error', failed);
|
||||
resolve(issue);
|
||||
};
|
||||
const loaded = () => finish();
|
||||
const failed = () => finish({ code: 'CORE_STYLE', path: `stylesheetUrls.${key}`, message: 'Required OrgChart stylesheet failed to load' });
|
||||
link.addEventListener('load', loaded); link.addEventListener('error', failed);
|
||||
styleDisposers.push(() => finish());
|
||||
shadow.append(link);
|
||||
}))).then(results => {
|
||||
const issues = results.filter((issue): issue is ValidationIssue => !!issue);
|
||||
return issues.length ? issues : undefined;
|
||||
});
|
||||
cancelStyles = () => { for (const dispose of styleDisposers) dispose(); };
|
||||
shell = document.createElement('section');
|
||||
shell.className = 'hm-orgchart-shell';
|
||||
// The Core's Korean labels must shape identically in ko HTML and en Storybook hosts.
|
||||
shell.lang = 'ko';
|
||||
shell.tabIndex = -1;
|
||||
shell.setAttribute('aria-label', '조직도 콘텐츠');
|
||||
status = document.createElement('p'); status.className = 'hm-orgchart-state'; status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite');
|
||||
content = document.createElement('div'); content.className = 'hm-orgchart-content';
|
||||
shell.append(status, content); shadow.append(shell); container.append(root);
|
||||
const Abort = document.defaultView?.AbortController ?? AbortController;
|
||||
interactions = new Abort();
|
||||
employeeDialog = createEmployeeInfoDialog(shell, interactions.signal, prefix, readOnly);
|
||||
content.addEventListener('click', activateEmployee, { signal: interactions.signal });
|
||||
content.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') activateEmployee(event);
|
||||
}, { signal: interactions.signal });
|
||||
await reload();
|
||||
},
|
||||
async update(input) {
|
||||
assertMounted();
|
||||
// Capture before begin() invokes callbacks that can mutate consumer input.
|
||||
let captured: unknown;
|
||||
try { captured = snapshot(input); }
|
||||
catch {
|
||||
const { version } = begin();
|
||||
fail(version, [{ code: 'INPUT_SNAPSHOT', path: '$', message: 'Input must be a cloneable JSON dataset' }]);
|
||||
return;
|
||||
}
|
||||
const { version } = begin();
|
||||
if (!active(version)) return;
|
||||
try { await apply(captured, version); }
|
||||
catch { fail(version, [{ code: 'CORE_RENDER', path: 'core', message: 'Chart rendering failed' }]); }
|
||||
},
|
||||
reload,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
++revision;
|
||||
request?.abort(); request = undefined;
|
||||
cancelStyles?.(); cancelStyles = undefined; styleReady = undefined;
|
||||
interactions?.abort(); interactions = undefined;
|
||||
employeeDialog = undefined;
|
||||
releaseView();
|
||||
root?.remove();
|
||||
if (container && owners.get(container) === owner) owners.delete(container);
|
||||
container = undefined; preferredContainer = undefined;
|
||||
root = undefined; shell = undefined; status = undefined; content = undefined;
|
||||
source = undefined; policy = undefined; Engine = undefined;
|
||||
listeners.clear();
|
||||
state = freeze({ status: 'destroyed' });
|
||||
},
|
||||
subscribe(listener) {
|
||||
if (destroyed) throw new Error('OrgChart handle is destroyed; create a new handle');
|
||||
if (typeof listener !== 'function') throw new TypeError('Listener must be a function');
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
},
|
||||
getState() { return state; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { RenderContext } from './render';
|
||||
import { createAffiliationMark } from './affiliation-mark';
|
||||
|
||||
export function createLogoLegend(document: Document, context: RenderContext, signal: AbortSignal): HTMLElement {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'hm-orgchart-logo-legend';
|
||||
const heading = document.createElement('h2'); heading.textContent = '관계사·회사 로고';
|
||||
const list = document.createElement('ul'); list.className = 'hm-orgchart-logo-list';
|
||||
for (const affiliation of context.data.affiliations) {
|
||||
const item = document.createElement('li');
|
||||
const name = document.createElement('span'); name.textContent = affiliation.name;
|
||||
item.append(createAffiliationMark(document, affiliation.id, affiliation.name, signal), name);
|
||||
list.append(item);
|
||||
}
|
||||
section.append(heading, list);
|
||||
return section;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { createEmployeeCard } from './employee-card.js';
|
||||
import { createHeading, type RenderContext } from './render.js';
|
||||
import { createPositionSummary } from './position-summary.js';
|
||||
import { createAffiliationSummary } from './affiliation-summary.js';
|
||||
import { createStatusSummary } from './status-summary.js';
|
||||
import { representativeCardAsset } from './representative-card-asset.js';
|
||||
import { createOrganizationTotals } from './organization-totals.js';
|
||||
import { createAffiliationBar } from './affiliation-bar.js';
|
||||
import { applyWorkdeskLayout } from './workdesk-layout.js';
|
||||
import { addWorkdeskArtwork } from './workdesk-artwork.js';
|
||||
|
||||
type SummaryComponent = 'PositionSummary' | 'AffiliationSummary' | 'StatusSummary';
|
||||
type ContentSlot = 'side' | 'footer' | 'belowTree';
|
||||
export interface ContentComposition {
|
||||
placements: Record<SummaryComponent, ContentSlot>;
|
||||
}
|
||||
/** Internal composition contract: move modules here without editing the tree,
|
||||
* recalculating data, adding React UI, or changing the npm public API. */
|
||||
export const defaultComposition: ContentComposition = {
|
||||
placements: { PositionSummary: 'side', AffiliationSummary: 'footer', StatusSummary: 'belowTree' },
|
||||
};
|
||||
|
||||
export function createOrgChartContent(
|
||||
document: Document, context: RenderContext, tree: HTMLElement,
|
||||
signal: AbortSignal, composition: ContentComposition = defaultComposition,
|
||||
): HTMLElement {
|
||||
// Scale wrapper (`content`) > Base Stage (`stage`). Only the stage is transformed.
|
||||
const readOnly = context.readOnly === true;
|
||||
const content = document.createElement('div');
|
||||
content.className = 'hm-orgchart-composition';
|
||||
content.dataset.component = 'OrgChartContent';
|
||||
content.dataset.displayMode = readOnly ? 'readOnly' : 'interactive';
|
||||
const stage = document.createElement('div');
|
||||
stage.className = 'hm-orgchart-stage';
|
||||
content.append(stage);
|
||||
const main = document.createElement('div');
|
||||
main.className = 'hm-orgchart-main';
|
||||
const heading = createHeading(document, context);
|
||||
const leaders = document.createElement('section');
|
||||
leaders.className = 'hm-orgchart-top-leaders';
|
||||
leaders.setAttribute('aria-label', '최상위 조직 리더');
|
||||
for (const id of context.topLeaderIds) {
|
||||
const employee = context.data.employees.find(item => item.id === id)!;
|
||||
leaders.append(createEmployeeCard(document, employee, {
|
||||
kind: 'topLeader',
|
||||
workdeskPresentation: readOnly,
|
||||
cardAsset: representativeCardAsset,
|
||||
affiliationName: context.data.affiliations.find(item => item.id === employee.affiliationId)?.name,
|
||||
}, signal));
|
||||
}
|
||||
const hub = document.createElement('p');
|
||||
hub.className = 'hm-orgchart-hub';
|
||||
hub.dataset.component = 'CollaborationHub';
|
||||
hub.textContent = context.data.presentation?.hubText ?? '';
|
||||
if (readOnly && hub.textContent) {
|
||||
// Actual inline icon from Workdesk index.html .hub-icon; Frame2 is decoration.
|
||||
const label = document.createElement('span');
|
||||
label.className = 'hm-orgchart-hub-label';
|
||||
label.textContent = hub.textContent;
|
||||
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
icon.classList.add('hm-orgchart-hub-icon');
|
||||
icon.setAttribute('viewBox', '0 0 24 24');
|
||||
icon.setAttribute('fill', 'none');
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.setAttribute('focusable', 'false');
|
||||
for (const d of ['M8.5 11.2h7', 'M8.5 15h4.7', 'M7.2 18.6 4 21v-4.7a7.2 7.2 0 0 1-1.6-4.5C2.4 7.6 6.2 4.2 11 4.2s8.6 3.4 8.6 7.6-3.8 7.6-8.6 7.6c-1.3 0-2.6-.3-3.8-.8Z']) {
|
||||
const path = document.createElementNS(icon.namespaceURI, 'path');
|
||||
path.setAttribute('d', d);
|
||||
icon.append(path);
|
||||
}
|
||||
hub.replaceChildren(icon, label);
|
||||
}
|
||||
if (readOnly) {
|
||||
// Title on the left; the top leader and hub share the canvas axis column.
|
||||
const axis = document.createElement('div');
|
||||
axis.className = 'hm-orgchart-axis';
|
||||
if (leaders.childElementCount) axis.append(leaders);
|
||||
if (hub.textContent) axis.append(hub);
|
||||
main.append(heading, axis);
|
||||
// No scroll container in the fitted canvas: a labelled, non-focusable region.
|
||||
tree.removeAttribute('tabindex');
|
||||
tree.setAttribute('aria-label', '조직 계층도');
|
||||
if (applyWorkdeskLayout(tree)) addWorkdeskArtwork(stage, hub, signal);
|
||||
} else {
|
||||
const intro = document.createElement('div');
|
||||
intro.className = 'hm-orgchart-intro';
|
||||
intro.append(heading);
|
||||
if (leaders.childElementCount) intro.append(leaders);
|
||||
main.append(intro);
|
||||
if (hub.textContent) main.append(hub);
|
||||
}
|
||||
main.append(tree);
|
||||
const slots = { side: document.createElement('aside'), footer: document.createElement('div'), belowTree: document.createElement('div') };
|
||||
for (const [slot, element] of Object.entries(slots)) {
|
||||
element.className = `hm-orgchart-slot-${slot}`;
|
||||
element.dataset.contentSlot = slot;
|
||||
}
|
||||
slots.side.setAttribute('aria-label', '조직도 인원 현황');
|
||||
const groups = context.data.presentation?.groups;
|
||||
// Each module consumes the same context and can be hidden independently.
|
||||
// Read-only uses compact presenters of the same Summary; standalone components are unchanged.
|
||||
const modules: [SummaryComponent, boolean, () => HTMLElement][] = [
|
||||
['PositionSummary', groups?.showSummary !== false, () => readOnly ? createOrganizationTotals(document, context) : createPositionSummary(document, context)],
|
||||
['AffiliationSummary', groups?.showAffiliations !== false, () => readOnly ? createAffiliationBar(document, context, signal) : createAffiliationSummary(document, context)],
|
||||
['StatusSummary', groups?.showDispatched !== false || groups?.showOnLeave !== false, () => createStatusSummary(document, context, signal, { compact: readOnly })],
|
||||
];
|
||||
for (const [name, visible, create] of modules) {
|
||||
if (!visible) continue;
|
||||
const element = create();
|
||||
element.dataset.component = name;
|
||||
slots[composition.placements[name]].append(element);
|
||||
}
|
||||
if (slots.belowTree.childElementCount) main.append(slots.belowTree);
|
||||
stage.append(main);
|
||||
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);
|
||||
observeStageScale(content, stage, signal, readOnly);
|
||||
return content;
|
||||
}
|
||||
|
||||
/** Proportional fit: one uniform scale for the whole stage, never above 1.
|
||||
* The stage's offsetWidth/Height are unscaled layout sizes, so the wrapper
|
||||
* height is derived as height * scale. One observer and one pending frame per
|
||||
* instance; both end when the content generation aborts.
|
||||
* `fitHeight` fits the actual containing content box and viewport. It never
|
||||
* assumes a host Header or reserves an arbitrary 48px. */
|
||||
function observeStageScale(wrapper: HTMLElement, stage: HTMLElement, signal: AbortSignal, fitHeight = false): void {
|
||||
const view = wrapper.ownerDocument.defaultView;
|
||||
if (!view) return;
|
||||
let frame: number | undefined;
|
||||
let applied = { scale: NaN, width: NaN, height: NaN };
|
||||
const ancestors: HTMLElement[] = [];
|
||||
const collectAncestors = () => {
|
||||
// The constructor runs before insertion; discover the real host only once
|
||||
// the wrapper is connected. Refresh when a host reparents its chart too.
|
||||
const next: HTMLElement[] = [];
|
||||
let ancestor: HTMLElement | null = wrapper.parentElement;
|
||||
while (ancestor) {
|
||||
next.push(ancestor);
|
||||
const parent: HTMLElement | null = ancestor.parentElement;
|
||||
const root = ancestor.getRootNode();
|
||||
ancestor = parent ?? (root instanceof view.ShadowRoot ? root.host as HTMLElement : null);
|
||||
}
|
||||
if (fitHeight) {
|
||||
for (const element of ancestors) if (!next.includes(element)) observer.unobserve(element);
|
||||
for (const element of next) if (!ancestors.includes(element)) observer.observe(element);
|
||||
}
|
||||
ancestors.splice(0, ancestors.length, ...next);
|
||||
};
|
||||
const heightLimit = () => {
|
||||
const top = wrapper.getBoundingClientRect().top;
|
||||
let bottom = view.innerHeight;
|
||||
let trailingPadding = 0;
|
||||
for (const element of ancestors) {
|
||||
const css = view.getComputedStyle(element);
|
||||
const padding = parseFloat(css.paddingBottom) || 0;
|
||||
trailingPadding += padding;
|
||||
// Typed OM keeps 'auto' distinct from a resolved natural pixel height.
|
||||
// Falling back to an inline height still supports explicit host containers.
|
||||
const typed = (element as HTMLElement & { computedStyleMap?: () => Map<string, { toString(): string }> }).computedStyleMap?.();
|
||||
const height = typed?.get('height')?.toString() ?? element.style.height;
|
||||
const boundedHeight = (!!height && height !== 'auto') || css.maxHeight !== 'none';
|
||||
const constrained = boundedHeight && element !== wrapper.ownerDocument.body && element !== wrapper.ownerDocument.documentElement;
|
||||
if (constrained) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
bottom = Math.min(bottom, rect.bottom - (parseFloat(css.borderBottomWidth) || 0) - trailingPadding);
|
||||
}
|
||||
}
|
||||
return Math.max(0, Math.min(bottom - top, view.innerHeight - top - trailingPadding));
|
||||
};
|
||||
const apply = () => {
|
||||
frame = undefined;
|
||||
if (signal.aborted || !wrapper.isConnected) return;
|
||||
collectAncestors();
|
||||
const available = wrapper.clientWidth; // integer floor: never overflows the host
|
||||
const baseWidth = stage.offsetWidth;
|
||||
const baseHeight = stage.offsetHeight;
|
||||
if (!available || !baseWidth || !baseHeight) return;
|
||||
let scale = Math.min(1, available / baseWidth);
|
||||
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;
|
||||
const scaleChanged = scale !== applied.scale;
|
||||
applied = { scale, width: baseWidth, height: baseHeight };
|
||||
stage.style.transform = scale < 1 ? `scale(${scale})` : '';
|
||||
wrapper.style.height = `${baseHeight * scale}px`;
|
||||
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 }));
|
||||
};
|
||||
const schedule = () => { if (frame === undefined && !signal.aborted) frame = view.requestAnimationFrame(apply); };
|
||||
const observer = new view.ResizeObserver(schedule);
|
||||
observer.observe(wrapper);
|
||||
observer.observe(stage);
|
||||
wrapper.ownerDocument.fonts?.addEventListener('loadingdone', schedule, { signal });
|
||||
// Viewport height changes do not resize the wrapper; observe them directly.
|
||||
if (fitHeight) view.addEventListener('resize', schedule, { signal });
|
||||
signal.addEventListener('abort', () => {
|
||||
observer.disconnect();
|
||||
if (frame !== undefined) view.cancelAnimationFrame(frame);
|
||||
frame = undefined;
|
||||
}, { once: true });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Stable engine/focus boundary; summaries never become engine nodes. */
|
||||
export function createOrgChartTree(document: Document): HTMLElement {
|
||||
const viewport = document.createElement('div');
|
||||
viewport.className = 'hm-orgchart-viewport';
|
||||
viewport.dataset.component = 'OrgChartTree';
|
||||
viewport.tabIndex = 0;
|
||||
viewport.setAttribute('role', 'region');
|
||||
viewport.setAttribute('aria-label', '조직 계층도 가로 스크롤 영역');
|
||||
return viewport;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { selectMajorOrganizationTotals, selectOrganizationPositionCounts } from '../data/content-selectors.js';
|
||||
import type { RenderContext } from './render.js';
|
||||
|
||||
function text(document: Document, tag: string, className: string, value: string): HTMLElement {
|
||||
const element = document.createElement(tag);
|
||||
element.className = className;
|
||||
element.textContent = value;
|
||||
return element;
|
||||
}
|
||||
|
||||
/** Read-only Workdesk matrix: disjoint major organization rows, position
|
||||
* columns and one subtotal. The independent PositionSummary retains its
|
||||
* detailed direct/inclusive counts, aggregation policy and public API. */
|
||||
export function createOrganizationTotals(document: Document, context: RenderContext): HTMLElement {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'hm-orgchart-organization-totals';
|
||||
section.dataset.contentSection = 'organization-totals';
|
||||
const title = text(document, 'h3', 'hm-orgchart-organization-totals-title', '직위별 인원 현황 (팀별)');
|
||||
section.append(title);
|
||||
|
||||
// Keep the source's five position columns where they exist. Additional
|
||||
// declared positions remain visible rather than being dropped or relabelled.
|
||||
const declaredPositions = Object.keys(context.summary.raw.byPosition);
|
||||
const preferredPositions = ['경영진', '수석', '책임', '선임', '연구'];
|
||||
const positions = [
|
||||
...preferredPositions.filter(position => declaredPositions.includes(position)),
|
||||
...declaredPositions.filter(position => !preferredPositions.includes(position)),
|
||||
];
|
||||
section.style.setProperty('--hm-orgchart-workdesk-summary-width', `${84 + (positions.length + 1) * 41}px`);
|
||||
const matrix = selectOrganizationPositionCounts(context.data);
|
||||
const children = new Map<string, string[]>();
|
||||
for (const organization of context.data.organizations) {
|
||||
if (organization.parentId === null) continue;
|
||||
const siblings = children.get(organization.parentId) ?? [];
|
||||
siblings.push(organization.id);
|
||||
children.set(organization.parentId, siblings);
|
||||
}
|
||||
const frame = document.createElement('div');
|
||||
frame.className = 'hm-orgchart-workdesk-summary-frame';
|
||||
const table = document.createElement('table');
|
||||
table.className = 'hm-orgchart-workdesk-position-table';
|
||||
table.setAttribute('aria-label', '직위별 인원 현황 (팀별)');
|
||||
const columns = document.createElement('colgroup');
|
||||
for (let index = 0; index < positions.length + 2; index++) {
|
||||
const column = document.createElement('col');
|
||||
column.className = index === 0 ? 'hm-orgchart-workdesk-label-column' : 'hm-orgchart-workdesk-value-column';
|
||||
columns.append(column);
|
||||
}
|
||||
const head = document.createElement('thead');
|
||||
const header = document.createElement('tr');
|
||||
for (const label of ['팀원', ...positions, '소계']) {
|
||||
const cell = document.createElement('th');
|
||||
cell.scope = 'col';
|
||||
cell.textContent = label;
|
||||
header.append(cell);
|
||||
}
|
||||
head.append(header);
|
||||
const body = document.createElement('tbody');
|
||||
for (const { organization, scope, count } of selectMajorOrganizationTotals(context.data, context.summary)) {
|
||||
const row = document.createElement('tr');
|
||||
row.dataset.organizationId = organization.id;
|
||||
row.dataset.countScope = scope;
|
||||
const label = document.createElement('th');
|
||||
label.scope = 'row';
|
||||
label.textContent = organization.name;
|
||||
row.append(label);
|
||||
const organizations = [organization.id];
|
||||
if (scope === 'inclusive') {
|
||||
for (let index = 0; index < organizations.length; index++) organizations.push(...children.get(organizations[index]!) ?? []);
|
||||
}
|
||||
for (const position of positions) {
|
||||
const positionCount = organizations.reduce((sum, id) => sum + (matrix.get(id)?.get(position) ?? 0), 0);
|
||||
const cell = text(document, 'td', 'hm-orgchart-workdesk-count-cell', String(positionCount));
|
||||
cell.dataset.position = position;
|
||||
row.append(cell);
|
||||
}
|
||||
row.append(text(document, 'td', 'hm-orgchart-workdesk-count-cell hm-orgchart-workdesk-subtotal', String(count)));
|
||||
body.append(row);
|
||||
}
|
||||
const foot = document.createElement('tfoot');
|
||||
const total = document.createElement('tr');
|
||||
const totalLabel = document.createElement('th');
|
||||
totalLabel.scope = 'row';
|
||||
totalLabel.textContent = '계';
|
||||
total.append(totalLabel);
|
||||
for (const position of positions) total.append(text(document, 'td', 'hm-orgchart-workdesk-count-cell', String(context.summary.raw.byPosition[position] ?? 0)));
|
||||
const totalCount = text(document, 'td', 'hm-orgchart-workdesk-count-cell hm-orgchart-workdesk-subtotal', String(context.summary.raw.total));
|
||||
totalCount.dataset.totalCount = String(context.summary.raw.total);
|
||||
total.append(totalCount);
|
||||
foot.append(total);
|
||||
table.append(columns, head, body, foot);
|
||||
frame.append(table);
|
||||
section.append(frame);
|
||||
return section;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
let tooltipSequence = 0;
|
||||
|
||||
/** Presentation-only overflow. Keep the complete text node for accessibility;
|
||||
* cropped names enter the tab order and expose a hoverable/dismissible tooltip.
|
||||
* Each observer/listener/tooltip belongs to the existing card abort boundary. */
|
||||
export function observePersonNameOverflow(card: HTMLElement, name: HTMLElement, signal: AbortSignal): void {
|
||||
const document = card.ownerDocument;
|
||||
const window = document.defaultView;
|
||||
if (!window) return;
|
||||
const tooltip = document.createElement('span');
|
||||
tooltip.id = `hm-orgchart-person-name-tip-${++tooltipSequence}`;
|
||||
tooltip.className = 'hm-orgchart-person-name-tooltip';
|
||||
tooltip.setAttribute('role', 'tooltip');
|
||||
tooltip.textContent = name.textContent;
|
||||
tooltip.hidden = true;
|
||||
let timer: number | undefined;
|
||||
let hoveringTooltip = false;
|
||||
let scaleListening = false;
|
||||
const focused = () => (name.getRootNode() as ShadowRoot).activeElement === name;
|
||||
function cancelTimer(): void { if (timer !== undefined) window!.clearTimeout(timer); timer = undefined; }
|
||||
function hide(): void { cancelTimer(); tooltip.hidden = true; name.dataset.tooltipOpen = 'false'; }
|
||||
function refresh(): void {
|
||||
if (signal.aborted || !name.isConnected || name.clientWidth === 0) return;
|
||||
const overflow = name.scrollWidth > name.clientWidth;
|
||||
name.dataset.nameOverflow = String(overflow);
|
||||
if (overflow) {
|
||||
name.tabIndex = 0;
|
||||
name.setAttribute('aria-describedby', tooltip.id);
|
||||
// Fixed positioning avoids adding hidden content to tree/document scroll
|
||||
// extents. Append inside the same Shadow Root for IDREF accessibility.
|
||||
const shell = card.closest('.hm-orgchart-shell');
|
||||
shell?.append(tooltip);
|
||||
// Full Composition scale changes move cards without a window resize.
|
||||
if (shell && !scaleListening) { scaleListening = true; shell.addEventListener('hm-orgchart-scale', hide, { signal }); }
|
||||
} else {
|
||||
name.removeAttribute('tabindex');
|
||||
name.removeAttribute('aria-describedby');
|
||||
hide();
|
||||
tooltip.remove();
|
||||
}
|
||||
}
|
||||
function show(): void {
|
||||
cancelTimer();
|
||||
refresh();
|
||||
if (name.dataset.nameOverflow !== 'true') return;
|
||||
tooltip.hidden = false;
|
||||
name.dataset.tooltipOpen = 'true';
|
||||
const bounds = name.getBoundingClientRect();
|
||||
const tip = tooltip.getBoundingClientRect();
|
||||
const left = Math.max(8, Math.min(bounds.left, window!.innerWidth - tip.width - 8));
|
||||
const below = bounds.bottom + 6;
|
||||
const top = below + tip.height <= window!.innerHeight - 8 ? below : Math.max(8, bounds.top - tip.height - 6);
|
||||
tooltip.style.left = `${left}px`;
|
||||
tooltip.style.top = `${top}px`;
|
||||
}
|
||||
name.addEventListener('mouseenter', show, { signal });
|
||||
name.addEventListener('mouseleave', () => {
|
||||
if (!focused()) timer = window.setTimeout(() => { if (!hoveringTooltip) hide(); }, 180);
|
||||
}, { signal });
|
||||
name.addEventListener('focus', show, { signal });
|
||||
name.addEventListener('blur', () => { if (!hoveringTooltip) hide(); }, { signal });
|
||||
name.addEventListener('keydown', event => { if (event.key === 'Escape') hide(); }, { signal });
|
||||
tooltip.addEventListener('mouseenter', () => { hoveringTooltip = true; cancelTimer(); }, { signal });
|
||||
tooltip.addEventListener('mouseleave', () => { hoveringTooltip = false; if (!focused()) hide(); }, { signal });
|
||||
window.addEventListener('scroll', hide, { capture: true, signal });
|
||||
window.addEventListener('resize', hide, { signal });
|
||||
document.fonts.addEventListener('loadingdone', refresh, { signal });
|
||||
const observer = new window.ResizeObserver(refresh);
|
||||
observer.observe(name);
|
||||
void document.fonts.ready.then(refresh);
|
||||
signal.addEventListener('abort', () => { cancelTimer(); observer.disconnect(); tooltip.remove(); }, { once: true });
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { selectOrganizationPositionCounts } from '../data/content-selectors.js';
|
||||
import type { RenderContext } from './render.js';
|
||||
|
||||
function text(document: Document, tag: string, className: string, value: string): HTMLElement {
|
||||
const element = document.createElement(tag);
|
||||
element.className = className;
|
||||
element.textContent = value;
|
||||
return element;
|
||||
}
|
||||
|
||||
/** Independent, passive content component; composition controls its visibility and placement. */
|
||||
export function createPositionSummary(document: Document, context: RenderContext): HTMLElement {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'hm-orgchart-summary hm-orgchart-position-summary';
|
||||
section.setAttribute('aria-label', '조직 및 직위별 원본 인원 현황');
|
||||
section.dataset.contentSection = 'position-summary';
|
||||
section.append(text(document, 'h3', 'hm-orgchart-summary-title', '조직 · 직위별 인원 현황'));
|
||||
section.append(text(document, 'p', 'hm-orgchart-raw-total', `Raw Count · 원본 직원 전체 ${context.summary.raw.total}명`));
|
||||
section.append(text(document, 'p', 'hm-orgchart-policy', '원본 직원 ID를 직접 소속 조직에서 한 번 집계합니다. 조직장 · 파견 · 휴직을 포함하며 업무 총원 정책을 뜻하지 않습니다.'));
|
||||
section.append(text(document, 'p', 'hm-orgchart-policy', context.summary.aggregation.status === 'ready'
|
||||
? `승인 정책 ${context.summary.aggregation.policyVersion}: 총원 ${context.summary.aggregation.counts.total}명`
|
||||
: '정책 총원 미확정: 집계 정책 승인이 필요합니다.'));
|
||||
|
||||
const scroll = document.createElement('div');
|
||||
scroll.className = 'hm-orgchart-summary-scroll';
|
||||
scroll.tabIndex = 0;
|
||||
scroll.setAttribute('role', 'region');
|
||||
scroll.setAttribute('aria-label', '조직 및 직위별 인원 표, 가로 스크롤 가능');
|
||||
const table = document.createElement('table');
|
||||
table.className = 'hm-orgchart-position-table';
|
||||
const caption = document.createElement('caption');
|
||||
caption.textContent = '직위별 교차 인원과 소계는 직접 소속 기준입니다. 하위 포함 수는 서로 합산하지 않습니다.';
|
||||
const positions = Object.keys(context.summary.raw.byPosition);
|
||||
const matrix = selectOrganizationPositionCounts(context.data);
|
||||
const head = document.createElement('thead');
|
||||
const headerRow = document.createElement('tr');
|
||||
for (const label of ['조직', ...positions, '소계 (직접)', '하위 포함']) {
|
||||
const cell = document.createElement('th');
|
||||
cell.scope = 'col';
|
||||
cell.textContent = label;
|
||||
headerRow.append(cell);
|
||||
}
|
||||
head.append(headerRow);
|
||||
const body = document.createElement('tbody');
|
||||
for (const organization of context.orderedOrganizations) {
|
||||
const row = document.createElement('tr');
|
||||
row.dataset.organizationId = organization.id;
|
||||
const name = document.createElement('th');
|
||||
name.scope = 'row';
|
||||
name.textContent = organization.name;
|
||||
row.append(name);
|
||||
for (const position of positions) row.append(text(document, 'td', 'hm-orgchart-count-cell', String(matrix.get(organization.id)?.get(position) ?? 0)));
|
||||
const counts = context.summary.raw.byOrganization[organization.id];
|
||||
row.append(text(document, 'td', 'hm-orgchart-count-cell hm-orgchart-subtotal', String(counts?.direct ?? 0)));
|
||||
row.append(text(document, 'td', 'hm-orgchart-count-cell hm-orgchart-inclusive', String(counts?.inclusive ?? 0)));
|
||||
body.append(row);
|
||||
}
|
||||
const foot = document.createElement('tfoot');
|
||||
const totalRow = document.createElement('tr');
|
||||
const totalLabel = document.createElement('th');
|
||||
totalLabel.scope = 'row';
|
||||
totalLabel.textContent = '전체 합계';
|
||||
totalRow.append(totalLabel);
|
||||
for (const position of positions) totalRow.append(text(document, 'td', 'hm-orgchart-count-cell', String(context.summary.raw.byPosition[position] ?? 0)));
|
||||
totalRow.append(text(document, 'td', 'hm-orgchart-count-cell', String(context.summary.raw.total)));
|
||||
const inclusiveTotal = text(document, 'td', 'hm-orgchart-inclusive', '—');
|
||||
inclusiveTotal.setAttribute('aria-label', '하위 포함 수는 중복되어 합산하지 않음');
|
||||
totalRow.append(inclusiveTotal);
|
||||
foot.append(totalRow);
|
||||
table.append(caption, head, body, foot);
|
||||
scroll.append(table);
|
||||
section.append(scroll);
|
||||
|
||||
section.append(text(document, 'h4', 'hm-orgchart-group-title', '근무 상태별 원본 직원'));
|
||||
const statuses = document.createElement('dl');
|
||||
statuses.className = 'hm-orgchart-summary-group hm-orgchart-status-totals';
|
||||
for (const [key, label] of [['active', '재직'], ['dispatched', '파견'], ['onLeave', '휴직']] as const) {
|
||||
statuses.append(text(document, 'dt', '', label), text(document, 'dd', '', `${context.summary.raw.byStatus[key]}명`));
|
||||
}
|
||||
section.append(statuses);
|
||||
return section;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { Employee, OrgChartDataset, OrgChartSummary, OrganizationTreeNode } from '../data/index.js';
|
||||
import type { RenderEngineNode } from './engine.js';
|
||||
import { createEmployeeCard } from './employee-card.js';
|
||||
import { applyOrganizationStyle, organizationStylePresets } from './style-presets.js';
|
||||
import { getDepartmentCardAsset } from './department-card-assets.js';
|
||||
import type { OrgChartOrganizationLinks } from './types.js';
|
||||
|
||||
const organizationNames = { root: '최상위 조직', division: '부문', office: '실', div: 'Div', cell: '셀', team: '팀' } as const;
|
||||
|
||||
export interface RenderContext {
|
||||
data: OrgChartDataset;
|
||||
summary: OrgChartSummary;
|
||||
orderedOrganizations: OrganizationTreeNode['organization'][];
|
||||
employeesByOrganization: ReadonlyMap<string, readonly Employee[]>;
|
||||
topLeaderIds: ReadonlySet<string>;
|
||||
/** View-only composition: no collapse controls or diagnostic text are created. */
|
||||
readOnly?: boolean;
|
||||
organizationLinks?: OrgChartOrganizationLinks;
|
||||
}
|
||||
|
||||
export function engineTree(roots: OrganizationTreeNode[], prefix: string, context: RenderContext, startDepth = 0): RenderEngineNode[] {
|
||||
function convert(tree: OrganizationTreeNode, depth: number, variant: number): RenderEngineNode {
|
||||
variant = organizationStylePresets.find(preset => preset.organizationId === tree.organization.id)?.id ?? variant;
|
||||
const index = context.orderedOrganizations.length;
|
||||
context.orderedOrganizations.push(tree.organization);
|
||||
return {
|
||||
id: `${prefix}-org-${index}`, name: '', organizationIndex: index, depth, groupVariant: variant,
|
||||
...(tree.children.length ? { children: tree.children.map((child, childIndex) => convert(child, depth + 1, context.readOnly ? variant : depth === startDepth ? childIndex % 6 : variant)) } : {}),
|
||||
};
|
||||
}
|
||||
// Multiple actual roots stay independent; no fictitious organizational parent.
|
||||
return roots.map((tree, index) => convert(tree, startDepth, index % 6));
|
||||
}
|
||||
|
||||
function text(document: Document, tag: string, className: string, value: string): HTMLElement {
|
||||
const element = document.createElement(tag);
|
||||
element.className = className;
|
||||
element.textContent = value;
|
||||
return element;
|
||||
}
|
||||
|
||||
export function decorateNode(
|
||||
node: HTMLElement, value: RenderEngineNode, context: RenderContext,
|
||||
signal: AbortSignal, toggle: (node: HTMLElement) => boolean | undefined,
|
||||
): void {
|
||||
const organization = context.orderedOrganizations[Number(value.organizationIndex)];
|
||||
if (!organization) return;
|
||||
const document = node.ownerDocument;
|
||||
const card = node.querySelector('.hm-orgchart-organization')!;
|
||||
node.dataset.organizationId = organization.id;
|
||||
node.dataset.depth = String(value.depth);
|
||||
// Engine roots can be flattened for composition. Card policy uses the
|
||||
// original ancestry rather than the engine's display depth or job title.
|
||||
let organizationDepth = 0;
|
||||
let ancestor = organization;
|
||||
while (ancestor.parentId) {
|
||||
const parent = context.data.organizations.find(item => item.id === ancestor.parentId);
|
||||
if (!parent) break;
|
||||
organizationDepth++;
|
||||
ancestor = parent;
|
||||
}
|
||||
node.dataset.organizationDepth = String(organizationDepth);
|
||||
applyOrganizationStyle(node, Number(value.groupVariant));
|
||||
node.dataset.organizationType = organization.type;
|
||||
// Header role is styling-only: depth 0-1 are organization titles, deeper nodes are cell/team headers.
|
||||
node.dataset.headerRole = (context.readOnly ? organizationDepth : Number(value.depth)) <= 1 ? 'organization' : 'group';
|
||||
// Hide upstream mouse-only arrows, retaining the engine's hierarchy connectors.
|
||||
for (const edge of node.querySelectorAll('.edge')) edge.setAttribute('aria-hidden', 'true');
|
||||
const readOnly = context.readOnly === true;
|
||||
// Kind, ID, raw counts and vacancy notes are diagnostic; read-only shows the header and people only.
|
||||
if (!readOnly) card.append(text(document, 'span', 'hm-orgchart-kind', organizationNames[organization.type]));
|
||||
const heading = text(document, 'h3', 'hm-orgchart-organization-name', organization.name);
|
||||
const href = organizationDepth === 1 ? context.organizationLinks?.[organization.id] : undefined;
|
||||
if (href) {
|
||||
const link = document.createElement('a');
|
||||
link.className = 'hm-orgchart-organization-link';
|
||||
link.href = href;
|
||||
link.textContent = organization.name;
|
||||
heading.replaceChildren(link);
|
||||
// Native links retain keyboard and browser navigation semantics; this
|
||||
// click must not also reach the upstream node selection/collapse handler.
|
||||
link.addEventListener('click', event => event.stopPropagation(), { signal });
|
||||
link.addEventListener('keydown', event => event.stopPropagation(), { signal });
|
||||
}
|
||||
card.append(heading);
|
||||
if (!readOnly) {
|
||||
const id = text(document, 'span', 'hm-orgchart-domain-id', organization.id);
|
||||
id.title = organization.id;
|
||||
card.append(id);
|
||||
const counts = context.summary.raw.byOrganization[organization.id];
|
||||
card.append(text(document, 'p', 'hm-orgchart-node-count', `원본 직원: 직접 ${counts?.direct ?? 0}명 · 하위 포함 ${counts?.inclusive ?? 0}명`));
|
||||
}
|
||||
const leader = organization.leaderId ? context.data.employees.find(employee => employee.id === organization.leaderId) : undefined;
|
||||
const affiliationName = (employee: Employee) => context.data.affiliations.find(item => item.id === employee.affiliationId)?.name;
|
||||
if (leader && !context.topLeaderIds.has(leader.id)) card.append(createEmployeeCard(document, leader, {
|
||||
kind: 'leader', affiliationName: affiliationName(leader), workdeskPresentation: readOnly,
|
||||
cardAsset: getDepartmentCardAsset(organization.id, readOnly && organizationDepth !== 1 ? 'employee' : 'leader', context.data.organizations),
|
||||
}, signal));
|
||||
else if (readOnly) { /* The top leader card or an empty slot needs no placeholder text. */ }
|
||||
else if (leader) card.append(text(document, 'p', 'hm-orgchart-vacancy', '조직장: 상단 독립 카드'));
|
||||
else card.append(text(document, 'p', 'hm-orgchart-vacancy', '조직장 미지정'));
|
||||
if (context.data.presentation?.groups.showEmployees !== false) {
|
||||
const employees = document.createElement('div');
|
||||
employees.className = 'hm-orgchart-employees';
|
||||
for (const employee of context.employeesByOrganization.get(organization.id) ?? []) {
|
||||
if (employee.id !== leader?.id && !context.topLeaderIds.has(employee.id)) employees.append(createEmployeeCard(document, employee, { kind: 'employee', workdeskPresentation: readOnly, affiliationName: affiliationName(employee), cardAsset: getDepartmentCardAsset(organization.id, 'employee', context.data.organizations) }, signal));
|
||||
}
|
||||
card.append(employees);
|
||||
}
|
||||
// Read-only hierarchy is always fully expanded; no toggle (and no leftover space) is created.
|
||||
if (value.children?.length && !readOnly) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'hm-orgchart-toggle';
|
||||
button.textContent = '하위 조직 접기';
|
||||
button.setAttribute('aria-label', `${organization.name} 하위 조직 접기`);
|
||||
button.setAttribute('aria-expanded', 'true');
|
||||
button.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
const expanded = toggle(node);
|
||||
if (expanded === undefined) return;
|
||||
button.setAttribute('aria-expanded', String(expanded));
|
||||
const label = expanded ? '하위 조직 접기' : '하위 조직 펼치기';
|
||||
button.textContent = label;
|
||||
button.setAttribute('aria-label', `${organization.name} ${label}`);
|
||||
}, { signal });
|
||||
card.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
export function createHeading(document: Document, context: RenderContext): HTMLElement {
|
||||
const header = document.createElement('header');
|
||||
header.className = 'hm-orgchart-heading';
|
||||
header.append(text(document, 'h2', 'hm-orgchart-title', context.data.presentation?.title ?? '조직도'));
|
||||
// Read-only keeps the title and as-of date; description and relation notes are explanatory text.
|
||||
if (context.data.presentation?.description && !context.readOnly) header.append(text(document, 'p', 'hm-orgchart-description', context.data.presentation.description));
|
||||
header.append(text(document, 'p', 'hm-orgchart-date', `기준일 ${context.data.metadata.asOfDate}`));
|
||||
if (context.data.relations?.length && !context.readOnly) header.append(text(document, 'p', 'hm-orgchart-description', `추가 관계 ${context.data.relations.length}건은 이번 기본 조직 계층선에 포함되지 않습니다.`));
|
||||
return header;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import url from '../assets/cards/대표자.svg?inline';
|
||||
|
||||
export interface RepresentativeCardAsset {
|
||||
size: 'representative';
|
||||
label: string;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Separate from department mapping: only the standalone topLeader uses it. */
|
||||
export const representativeCardAsset: RepresentativeCardAsset = {
|
||||
size: 'representative', label: '대표자 전용', url, width: 146, height: 74,
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { selectStatusGroups } from '../data/content-selectors.js';
|
||||
import { createEmployeeCard } from './employee-card.js';
|
||||
import type { RenderContext } from './render.js';
|
||||
|
||||
/** `compact` is the read-only composition form: label-only headers and no policy note. */
|
||||
export function createStatusSummary(document: Document, context: RenderContext, signal: AbortSignal, { compact = false } = {}): HTMLElement {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'hm-orgchart-status-summary';
|
||||
section.dataset.contentSection = 'status-summary';
|
||||
section.setAttribute('aria-label', '파견 및 휴직 직원');
|
||||
const affiliations = new Map(context.data.affiliations.map(affiliation => [affiliation.id, affiliation.name]));
|
||||
for (const { status, employees, count } of selectStatusGroups(context.data, context.summary)) {
|
||||
const group = document.createElement('section');
|
||||
group.className = 'hm-orgchart-status-group';
|
||||
group.dataset.workStatus = status;
|
||||
const label = status === 'dispatched' ? '파견' : '휴직';
|
||||
group.setAttribute('aria-label', `${label} 직원`);
|
||||
const heading = document.createElement('h3');
|
||||
heading.className = 'hm-orgchart-summary-title';
|
||||
heading.textContent = compact ? label : `${label} · ${count}명`;
|
||||
if (compact) heading.setAttribute('aria-label', `${label} ${count}명`);
|
||||
group.append(heading);
|
||||
const cards = document.createElement('div');
|
||||
cards.className = 'hm-orgchart-status-employees';
|
||||
for (const employee of employees) {
|
||||
cards.append(createEmployeeCard(document, employee, { kind: 'employee', workdeskPresentation: context.readOnly,
|
||||
hideStatusLabel: compact && context.readOnly, affiliationName: affiliations.get(employee.affiliationId) }, signal));
|
||||
}
|
||||
group.append(cards);
|
||||
if (!employees.length) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'hm-orgchart-content-empty';
|
||||
empty.textContent = `${label} 직원이 없습니다.`;
|
||||
group.append(empty);
|
||||
}
|
||||
if (!compact) {
|
||||
const note = document.createElement('p');
|
||||
note.className = 'hm-orgchart-policy';
|
||||
note.textContent = '원본 직원의 상태별 별도 표시입니다. 전체 인원에 중복 합산하지 않습니다.';
|
||||
group.append(note);
|
||||
}
|
||||
section.append(group);
|
||||
}
|
||||
return section;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/** Presentation-only colors measured from Workdesk organization headers.
|
||||
* Header measurements do not verify employee card edges/fills. Standalone
|
||||
* OrgChart defaults and employee JSON remain separate from this local config. */
|
||||
const status = 'WORKDESK_COMPUTED' as const;
|
||||
const source = 'https://workdesk.hmac.kr/' as const;
|
||||
const observedOn = '2026-10-12' as const;
|
||||
const version = 'v1.2610.9c5e' as const;
|
||||
const foregroundVariable = '--hm-orgchart-resolved-on-action-primary' as const;
|
||||
const cardBackground = 'var(--hm-orgchart-resolved-bg-surface)' as const;
|
||||
const departmentColor = '#ffffff' as const;
|
||||
const cardStyleStatus = 'DESIGN_ADAPTATION' as const;
|
||||
const verificationScope = 'Organization header background only; employee card edges/fills NOT VERIFIED' as const;
|
||||
|
||||
export const organizationStylePresets = [
|
||||
{
|
||||
id: 0, label: '사업관리', accentColor: '#194d82',
|
||||
organizationId: 'business-management', variablePrefix: '--hm-orgchart-business-management',
|
||||
cardBackground, departmentColor, borderColor: '#194d82', cardStyleStatus,
|
||||
backgroundImage: 'linear-gradient(180deg, #194d82 0%, #1d395c 100%)',
|
||||
foregroundVariable, status, source, observedOn, version, selector: '.h33', verificationScope,
|
||||
},
|
||||
{
|
||||
id: 1, label: '경영기획', accentColor: '#2164a8',
|
||||
organizationId: 'business-planning', variablePrefix: '--hm-orgchart-business-planning',
|
||||
cardBackground, departmentColor, borderColor: '#2164a8', cardStyleStatus,
|
||||
backgroundImage: 'linear-gradient(180deg, #2164a8 0%, #254975 100%)',
|
||||
foregroundVariable, status, source, observedOn, version, selector: '.h33.org-team-link', verificationScope,
|
||||
},
|
||||
{
|
||||
id: 2, label: '인재성장', accentColor: '#0f6ace',
|
||||
organizationId: 'talent-growth', variablePrefix: '--hm-orgchart-talent-growth',
|
||||
cardBackground, departmentColor, borderColor: '#0f6ace', cardStyleStatus,
|
||||
backgroundImage: 'linear-gradient(180deg, #0f6ace 0%, #044a94 100%)',
|
||||
foregroundVariable, status, source, observedOn, version, selector: '.h38.org-team-link', verificationScope,
|
||||
},
|
||||
{
|
||||
id: 3, label: '통합시스템 Div', accentColor: '#8c67b6',
|
||||
organizationId: 'integrated-system', variablePrefix: '--hm-orgchart-integrated-system',
|
||||
cardBackground, departmentColor, borderColor: '#8c67b6', cardStyleStatus,
|
||||
backgroundImage: 'linear-gradient(180deg, #8c67b6 0%, #5d3494 100%)',
|
||||
foregroundVariable, status, source, observedOn, version, selector: '.h310.org-team-link', verificationScope,
|
||||
},
|
||||
{
|
||||
id: 4, label: '디자인기획', accentColor: '#09a7a2',
|
||||
organizationId: 'design-planning', variablePrefix: '--hm-orgchart-design-planning',
|
||||
cardBackground, departmentColor, borderColor: '#09a7a2', cardStyleStatus,
|
||||
backgroundImage: 'linear-gradient(180deg, #09a7a2 0%, #00847f 100%)',
|
||||
foregroundVariable, status, source, observedOn, version, selector: '.h312.org-team-link', verificationScope,
|
||||
},
|
||||
{
|
||||
id: 5, label: '솔루션영업', accentColor: '#5a963c',
|
||||
organizationId: 'solution-sales', variablePrefix: '--hm-orgchart-solution-sales',
|
||||
cardBackground, departmentColor, borderColor: '#5a963c', cardStyleStatus,
|
||||
backgroundImage: 'linear-gradient(180deg, #5a963c 0%, #3f7629 100%)',
|
||||
foregroundVariable, status, source, observedOn, version, selector: '.h36.org-team-link', verificationScope,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function applyOrganizationStyle(element: HTMLElement, variant: number): void {
|
||||
const preset = organizationStylePresets[variant] ?? organizationStylePresets[0];
|
||||
element.dataset.groupVariant = String(preset.id);
|
||||
const prefix = preset.variablePrefix;
|
||||
element.style.setProperty('--hm-orgchart-current-card-background',
|
||||
`var(${prefix}-card-background, var(--hm-orgchart-card-background, ${preset.cardBackground}))`);
|
||||
element.style.setProperty('--hm-orgchart-current-card-color',
|
||||
`var(${prefix}-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary)))`);
|
||||
element.style.setProperty('--hm-orgchart-current-department-color',
|
||||
`var(${prefix}-department-color, var(--hm-orgchart-department-color, ${preset.departmentColor}))`);
|
||||
element.style.setProperty('--hm-orgchart-current-border',
|
||||
`var(${prefix}-border, var(--hm-orgchart-border, ${preset.borderColor}))`);
|
||||
element.style.setProperty('--hm-orgchart-current-gradient',
|
||||
`var(${prefix}-gradient, var(--hm-orgchart-gradient, ${preset.backgroundImage}))`);
|
||||
// Compatibility aliases remain local to this presentation element.
|
||||
element.style.setProperty('--hm-orgchart-accent', 'var(--hm-orgchart-current-border)');
|
||||
element.style.setProperty('--hm-orgchart-group-background', 'var(--hm-orgchart-current-gradient)');
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Affiliation, ApprovedAggregationPolicy, Employee, OrgChartDataSource, OrgChartSummary, Organization, ValidationIssue } from '../data/index.js';
|
||||
|
||||
export type OrgChartState =
|
||||
| { readonly status: 'idle' | 'loading' | 'empty' | 'destroyed' }
|
||||
| { readonly status: 'ready'; readonly dataVersion: string; readonly summary: OrgChartSummary; readonly aggregation: OrgChartSummary['aggregation'] }
|
||||
| { readonly status: 'error'; readonly issues: readonly ValidationIssue[] };
|
||||
|
||||
/** 'interactive' (default) keeps collapse controls and diagnostic text.
|
||||
* 'readOnly' is the view-only Workdesk composition: expanded hierarchy, no
|
||||
* controls or development text, compact summaries on a fitted design canvas. */
|
||||
export type OrgChartDisplayMode = 'interactive' | 'readOnly';
|
||||
|
||||
/** Links are keyed by the existing organization ID. Only 1Depth titles use them. */
|
||||
export type OrgChartOrganizationLinks = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
export interface OrgChartEmployeeClickContext {
|
||||
readonly organization: Organization | undefined;
|
||||
readonly affiliation: Affiliation | undefined;
|
||||
/** Focusable card that activated this request, outside the default dialog. */
|
||||
readonly trigger: HTMLElement;
|
||||
readonly originalEvent: MouseEvent | KeyboardEvent;
|
||||
}
|
||||
|
||||
export interface OrgChartOptions {
|
||||
container?: HTMLElement;
|
||||
dataSource: OrgChartDataSource;
|
||||
aggregationPolicy?: ApprovedAggregationPolicy;
|
||||
/** Resolved CSS URLs inserted in the instance's Shadow DOM, in this order. */
|
||||
stylesheetUrls: { engine: string; core: string };
|
||||
/** Full composition presentation only; standalone views ignore it. */
|
||||
displayMode?: OrgChartDisplayMode;
|
||||
/** No entry (or an empty URL) keeps an ordinary title. HTTP(S) and relative URLs are supported. */
|
||||
organizationLinks?: OrgChartOrganizationLinks;
|
||||
/** 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;
|
||||
onStateChange?: (state: OrgChartState) => void;
|
||||
}
|
||||
|
||||
export interface OrgChartHandle {
|
||||
mount(container?: HTMLElement): Promise<void>;
|
||||
update(input: unknown): Promise<void>;
|
||||
reload(source?: OrgChartDataSource): Promise<void>;
|
||||
/** Terminal, idempotent disposal. Create a new handle for a later mount. */
|
||||
destroy(): void;
|
||||
subscribe(listener: (state: OrgChartState) => void): () => void;
|
||||
getState(): OrgChartState;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
declare module '*.svg?inline' {
|
||||
const url: string;
|
||||
export default url;
|
||||
}
|
||||
declare module '*.png?inline' {
|
||||
const url: string;
|
||||
export default url;
|
||||
}
|
||||
declare module 'orgchart' {
|
||||
export interface EngineNode { id: string; name: string; children?: EngineNode[]; [key: string]: unknown }
|
||||
export interface EngineOptions {
|
||||
data: EngineNode | EngineNode[];
|
||||
pan: boolean;
|
||||
zoom: boolean;
|
||||
minimap: boolean;
|
||||
draggable: boolean;
|
||||
exportButton: boolean;
|
||||
chartClass: string;
|
||||
nodeTemplate: () => string;
|
||||
createNode: (node: HTMLElement, data: EngineNode) => void;
|
||||
}
|
||||
export default class OrgChart {
|
||||
constructor(container: HTMLElement, options: EngineOptions);
|
||||
chart: HTMLElement | null;
|
||||
chartContainer: HTMLElement | null;
|
||||
opts: Partial<EngineOptions>;
|
||||
options: Partial<EngineOptions>;
|
||||
init(options?: Partial<EngineOptions>): OrgChart;
|
||||
triggerInitEvent(): void;
|
||||
handleCompactNodes(): void;
|
||||
getNodeState(node: HTMLElement, relation: string): { visible: boolean };
|
||||
hideChildren(node: HTMLElement): void;
|
||||
showChildren(node: HTMLElement): void;
|
||||
unbindPan(): void;
|
||||
unbindZoom(): void;
|
||||
detachMinimap(): void;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import ring from '../assets/workdesk/Group.svg?inline';
|
||||
import sky from '../assets/workdesk/bg_sky.png?inline';
|
||||
import meeting from '../assets/workdesk/bg_main.png?inline';
|
||||
import hubFrame from '../assets/workdesk/Frame2@3x.png?inline';
|
||||
import grid from '../assets/workdesk/1-8-4x-1@2x.png?inline';
|
||||
import blueShort from '../assets/workdesk/team-arrow-blue-short.png?inline';
|
||||
import bluePurple from '../assets/workdesk/team-arrow-blue-purple.png?inline';
|
||||
import purpleTeal from '../assets/workdesk/team-arrow-purple-teal.png?inline';
|
||||
import tealGreen from '../assets/workdesk/team-arrow-teal-green.png?inline';
|
||||
import { workdeskCanvas } from './workdesk-layout.js';
|
||||
|
||||
const svgNamespace = 'http://www.w3.org/2000/svg';
|
||||
const hubPaths = [
|
||||
['hub-to-business', 'M411.134 66.9559C456.067 45.0289 560.802 1.17493 695.801 1.17493'],
|
||||
['hub-to-design-solution', 'M1258.34 66.9559C1203.65 45.0289 1044.265 1.17493 848.038 1.17493'],
|
||||
['hub-to-teams-arc', 'M143.631104 74.8701C350.174 18.7487 874.423 -59.8214 1660.47 74.8701'],
|
||||
] as const;
|
||||
const arrows = [
|
||||
['blue-short-1', blueShort, 194.15, 311.75, 66.25, 19.8],
|
||||
['blue-short-2', blueShort, 430.40, 311.75, 66.25, 19.8],
|
||||
['blue-purple', bluePurple, 666.65, 302, 183.75, 42],
|
||||
['purple-teal', purpleTeal, 1044, 302, 258.916, 42],
|
||||
['teal-green', tealGreen, 1520.55, 305, 135.349, 36],
|
||||
] as const;
|
||||
|
||||
/** Local, byte-preserved Workdesk artwork embedded by the package bundler.
|
||||
* All geometry belongs to the same stage as the cards (one uniform scale).
|
||||
* Original compact screenD paths are from org-dashboard-data.js; the obsolete
|
||||
* static HTML fourth hub path and hidden executive branch are not rendered. */
|
||||
export function addWorkdeskArtwork(stage: HTMLElement, hub: HTMLElement, signal: AbortSignal): void {
|
||||
const document = stage.ownerDocument;
|
||||
stage.dataset.workdeskArtwork = 'true';
|
||||
const decoration = (id: string, source: string, parent = stage) => {
|
||||
const image = document.createElement('img');
|
||||
image.className = `hm-orgchart-workdesk-decoration hm-orgchart-workdesk-${id}`;
|
||||
image.dataset.workdeskAsset = id;
|
||||
image.alt = '';
|
||||
image.setAttribute('aria-hidden', 'true');
|
||||
image.draggable = false;
|
||||
image.src = source;
|
||||
parent.append(image);
|
||||
};
|
||||
decoration('ring', ring);
|
||||
decoration('sky', sky);
|
||||
decoration('meeting', meeting);
|
||||
decoration('grid', grid);
|
||||
decoration('hub-frame', hubFrame, hub);
|
||||
|
||||
const overlay = document.createElementNS(svgNamespace, 'svg');
|
||||
overlay.classList.add('hm-orgchart-workdesk-overlay');
|
||||
overlay.setAttribute('viewBox', `0 0 ${workdeskCanvas.width} ${workdeskCanvas.height}`);
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
overlay.setAttribute('focusable', 'false');
|
||||
const path = (id: string, d: string, parent: SVGElement = overlay) => {
|
||||
const element = document.createElementNS(svgNamespace, 'path');
|
||||
element.dataset.connector = id;
|
||||
element.setAttribute('d', d);
|
||||
element.setAttribute('fill', 'none');
|
||||
element.setAttribute('stroke', '#45639F');
|
||||
element.setAttribute('stroke-width', '0.893023');
|
||||
element.setAttribute('stroke-dasharray', '1.79 1.79');
|
||||
parent.append(element);
|
||||
return element;
|
||||
};
|
||||
const curves = document.createElementNS(svgNamespace, 'g');
|
||||
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 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');
|
||||
image.dataset.arrow = id;
|
||||
for (const [key, value] of Object.entries({ x, y, width, height, href: source, preserveAspectRatio: 'none' })) image.setAttribute(key, String(value));
|
||||
overlay.append(image);
|
||||
}
|
||||
stage.append(overlay);
|
||||
|
||||
// The original management curve follows the live header's top-center.
|
||||
// Convert transformed DOM bounds back to the stage coordinate system.
|
||||
const view = document.defaultView;
|
||||
if (!view) return;
|
||||
let frame: number | undefined;
|
||||
const update = () => {
|
||||
frame = undefined;
|
||||
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();
|
||||
const bounds = header.getBoundingClientRect();
|
||||
const scale = origin.width / stage.offsetWidth;
|
||||
if (!scale) return;
|
||||
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)}`);
|
||||
};
|
||||
const schedule = () => { if (frame === undefined && !signal.aborted) frame = view.requestAnimationFrame(update); };
|
||||
const observer = new view.ResizeObserver(schedule);
|
||||
observer.observe(stage);
|
||||
document.fonts?.addEventListener('loadingdone', schedule, { signal });
|
||||
view.addEventListener('resize', schedule, { signal });
|
||||
signal.addEventListener('abort', () => { observer.disconnect(); if (frame !== undefined) view.cancelAnimationFrame(frame); }, { once: true });
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Read-only composition geometry measured on the authenticated Workdesk
|
||||
* organization chart (v1.2610.9c5e, 2026-10-12). Presentation-only: keyed by
|
||||
* organization ID like style-presets; data, parentId and order stay untouched.
|
||||
* Organizations without an entry fall back to the centered flow layout. */
|
||||
export const workdeskCanvas = { width: 1920, height: 914.5, axisX: 935 } as const;
|
||||
|
||||
/** Header width equals the leader card (146px); columns are placed by header center. */
|
||||
const headerWidth = 146;
|
||||
|
||||
export interface WorkdeskColumnLayout {
|
||||
organizationId: string;
|
||||
/** Measured header left edge (px) on the 1920 canvas. */
|
||||
headerX: number;
|
||||
/** Child cells/teams: vertical stack (default) or one horizontal row. */
|
||||
children: 'vertical' | 'horizontal';
|
||||
}
|
||||
|
||||
export const workdeskColumns: readonly WorkdeskColumnLayout[] = [
|
||||
{ organizationId: 'business-management', headerX: 32, children: 'vertical' },
|
||||
{ organizationId: 'business-planning', headerX: 268, children: 'vertical' },
|
||||
{ organizationId: 'talent-growth', headerX: 505, children: 'vertical' },
|
||||
{ organizationId: 'integrated-system', headerX: 858, children: 'horizontal' },
|
||||
{ organizationId: 'design-planning', headerX: 1336, children: 'horizontal' },
|
||||
{ organizationId: 'solution-sales', headerX: 1689, children: 'vertical' },
|
||||
];
|
||||
|
||||
/** Applies column centers and child arrangement to the rendered engine forest.
|
||||
* Runs after every engine (re)build; returns false when any top-level
|
||||
* organization has no measured column, so the caller keeps the flow layout. */
|
||||
export function applyWorkdeskLayout(viewport: HTMLElement): boolean {
|
||||
const forest = viewport.querySelector<HTMLElement>('.hm-orgchart-forest');
|
||||
if (!forest) return false;
|
||||
const trees = [...forest.querySelectorAll<HTMLElement>(':scope > .hm-orgchart-tree')];
|
||||
const columns = trees.map(tree => {
|
||||
const id = tree.querySelector<HTMLElement>('.node[data-organization-id]')?.dataset.organizationId;
|
||||
return workdeskColumns.find(column => column.organizationId === id);
|
||||
});
|
||||
for (const node of forest.querySelectorAll<HTMLElement>('.node[data-organization-id]')) {
|
||||
const column = workdeskColumns.find(item => item.organizationId === node.dataset.organizationId);
|
||||
if (column?.children === 'horizontal') node.dataset.childrenLayout = 'horizontal';
|
||||
else delete node.dataset.childrenLayout;
|
||||
}
|
||||
const placed = trees.length > 0 && columns.every(Boolean);
|
||||
if (!placed) {
|
||||
delete forest.dataset.layout;
|
||||
for (const tree of trees) tree.style.removeProperty('--hm-orgchart-column-center');
|
||||
return false;
|
||||
}
|
||||
const centers = columns.map(column => column!.headerX + headerWidth / 2);
|
||||
trees.forEach((tree, index) => tree.style.setProperty('--hm-orgchart-column-center', `${centers[index]}px`));
|
||||
forest.dataset.layout = 'workdesk';
|
||||
forest.style.setProperty('--hm-orgchart-branch-start', `${Math.min(...centers)}px`);
|
||||
forest.style.setProperty('--hm-orgchart-branch-end', `${Math.max(...centers)}px`);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { selectAffiliationSummary, selectMajorOrganizationTotals, selectOrganizationPositionCounts, selectStatusGroups } from './content-selectors.js';
|
||||
import { calculateSummary } from './summary.js';
|
||||
import type { OrgChartDataset } from './types.js';
|
||||
|
||||
function fixture(): OrgChartDataset {
|
||||
return {
|
||||
schemaVersion: '0.1-draft',
|
||||
organizations: [
|
||||
{ id: 'root', parentId: null, name: '가상 본부', type: 'root', leaderId: 'leader', displayOrder: 0 },
|
||||
{ id: 'team', parentId: 'root', name: '가상 팀', type: 'team', leaderId: null, displayOrder: 1 },
|
||||
{ id: 'empty', parentId: 'root', name: '빈 가상 팀', type: 'team', leaderId: null, displayOrder: 2 },
|
||||
],
|
||||
employees: [
|
||||
{ id: 'leader', organizationId: 'root', affiliationId: 'first', name: '가상 가', position: '대표', jobTitle: '본부장', profileImage: null, workStatus: 'active' },
|
||||
{ id: 'sent', organizationId: 'team', affiliationId: 'second', name: '가상 나', position: '책임', jobTitle: null, profileImage: null, workStatus: 'dispatched' },
|
||||
{ id: 'leave', organizationId: 'team', affiliationId: 'first', name: '가상 다', position: '책임', jobTitle: null, profileImage: null, workStatus: 'onLeave' },
|
||||
],
|
||||
affiliations: [{ id: 'first', name: '가상 관계사 가' }, { id: 'second', name: '가상 관계사 나' }, { id: 'zero', name: '가상 관계사 다' }],
|
||||
metadata: { asOfDate: '2026-10-12', dataVersion: 'selector-test' },
|
||||
};
|
||||
}
|
||||
|
||||
test('position matrix uses direct primary membership, includes leaders and all work statuses', () => {
|
||||
const data = fixture();
|
||||
const matrix = selectOrganizationPositionCounts(data);
|
||||
const summary = calculateSummary(data);
|
||||
assert.deepEqual([...matrix.get('root')!], [['대표', 1]]);
|
||||
assert.deepEqual([...matrix.get('team')!], [['책임', 2]]);
|
||||
assert.equal(matrix.get('empty')!.size, 0);
|
||||
assert.equal(matrix.get('root')!.get('책임'), undefined);
|
||||
assert.equal(summary.raw.byOrganization.root!.inclusive, 3);
|
||||
for (const organization of data.organizations) {
|
||||
assert.equal([...matrix.get(organization.id)!.values()].reduce((sum, count) => sum + count, 0), summary.raw.byOrganization[organization.id]!.direct);
|
||||
}
|
||||
for (const [position, count] of Object.entries(summary.raw.byPosition)) {
|
||||
assert.equal([...matrix.values()].reduce((sum, row) => sum + (row.get(position) ?? 0), 0), count);
|
||||
}
|
||||
assert.equal([...matrix.values()].flatMap(row => [...row.values()]).reduce((sum, count) => sum + count, 0), summary.raw.total);
|
||||
assert.equal(summary.aggregation.status, 'unavailable');
|
||||
});
|
||||
|
||||
test('duplicate IDs cannot inflate matrix or status groups and original Employee references survive', () => {
|
||||
const data = fixture();
|
||||
data.employees.push({ ...data.employees[1]!, organizationId: 'root', position: '대표' });
|
||||
const summary = calculateSummary(data);
|
||||
const matrix = selectOrganizationPositionCounts(data);
|
||||
assert.equal(matrix.get('team')!.get('책임'), 2);
|
||||
assert.equal(matrix.get('root')!.get('대표'), 1);
|
||||
const groups = selectStatusGroups(data, summary);
|
||||
assert.equal(groups[0]!.employees.length, 1);
|
||||
assert.strictEqual(groups[0]!.employees[0], data.employees[1]);
|
||||
assert.strictEqual(groups[1]!.employees[0], data.employees[2]);
|
||||
assert.equal(summary.raw.total, 3);
|
||||
});
|
||||
|
||||
test('opaque prototype-like organization IDs and position strings remain ordinary matrix keys', () => {
|
||||
const data = fixture();
|
||||
data.organizations[1]!.id = '__proto__';
|
||||
data.employees[1]!.organizationId = '__proto__';
|
||||
data.employees[2]!.organizationId = '__proto__';
|
||||
data.employees[1]!.position = 'constructor';
|
||||
data.employees[2]!.position = '__proto__';
|
||||
const row = selectOrganizationPositionCounts(data).get('__proto__')!;
|
||||
assert.equal(row.get('constructor'), 1);
|
||||
assert.equal(row.get('__proto__'), 1);
|
||||
});
|
||||
|
||||
test('affiliation summary preserves declared order, zero entries and original affiliation objects', () => {
|
||||
const data = fixture();
|
||||
const summary = calculateSummary(data);
|
||||
const rows = selectAffiliationSummary(data, summary);
|
||||
assert.deepEqual(rows.map(row => [row.affiliation.id, row.count]), [['first', 2], ['second', 1], ['zero', 0]]);
|
||||
assert.strictEqual(rows[2]!.affiliation, data.affiliations[2]);
|
||||
assert.equal(rows.reduce((sum, row) => sum + row.count, 0), summary.raw.total);
|
||||
});
|
||||
|
||||
test('status flags independently hide groups without changing Raw Count or affiliation totals', () => {
|
||||
const data = fixture();
|
||||
const summary = calculateSummary(data);
|
||||
data.presentation = {
|
||||
title: '가상 조직도', description: null, hubText: null, organizationOrder: [],
|
||||
groups: { showEmployees: false, showSummary: false, showAffiliations: false, showDispatched: false, showOnLeave: true },
|
||||
};
|
||||
assert.deepEqual(selectStatusGroups(data, summary).map(group => group.status), ['onLeave']);
|
||||
data.presentation.groups.showDispatched = true;
|
||||
data.presentation.groups.showOnLeave = false;
|
||||
assert.deepEqual(selectStatusGroups(data, summary).map(group => group.status), ['dispatched']);
|
||||
data.presentation.groups.showDispatched = false;
|
||||
assert.deepEqual(selectStatusGroups(data, summary), []);
|
||||
assert.deepEqual(calculateSummary(data), summary);
|
||||
assert.equal(selectAffiliationSummary(data, summary).length, 3);
|
||||
});
|
||||
|
||||
test('zero employees retains all organization/affiliation rows and both empty status groups', () => {
|
||||
const data = fixture();
|
||||
data.employees = [];
|
||||
data.organizations[0]!.leaderId = null;
|
||||
const summary = calculateSummary(data);
|
||||
assert.equal(selectOrganizationPositionCounts(data).size, 3);
|
||||
assert.ok([...selectOrganizationPositionCounts(data).values()].every(row => row.size === 0));
|
||||
assert.ok(selectAffiliationSummary(data, summary).every(row => row.count === 0));
|
||||
assert.deepEqual(selectStatusGroups(data, summary).map(group => [group.status, group.count, group.employees.length]), [['dispatched', 0, 0], ['onLeave', 0, 0]]);
|
||||
});
|
||||
|
||||
test('new JSON snapshot updates matrix, affiliations and status groups without a cached count', () => {
|
||||
const data = fixture();
|
||||
const before = calculateSummary(data);
|
||||
data.employees[1]!.organizationId = 'root';
|
||||
data.employees[1]!.position = '수석';
|
||||
data.employees[1]!.affiliationId = 'zero';
|
||||
data.employees[1]!.workStatus = 'active';
|
||||
const after = calculateSummary(data);
|
||||
const matrix = selectOrganizationPositionCounts(data);
|
||||
assert.equal(matrix.get('root')!.get('수석'), 1);
|
||||
assert.equal(matrix.get('team')!.get('책임'), 1);
|
||||
assert.deepEqual(selectAffiliationSummary(data, after).map(row => row.count), [2, 0, 1]);
|
||||
assert.equal(selectStatusGroups(data, after)[0]!.employees.length, 0);
|
||||
assert.equal(before.raw.byStatus.dispatched, 1);
|
||||
assert.equal(after.raw.total, before.raw.total);
|
||||
});
|
||||
|
||||
test('major organization totals: root direct members then inclusive root children, summing to the raw total', () => {
|
||||
const data = fixture();
|
||||
data.organizations.push({ id: 'cell', parentId: 'team', name: '가상 셀', type: 'cell', leaderId: null, displayOrder: 3 });
|
||||
data.employees.push({ id: 'member', organizationId: 'cell', affiliationId: 'zero', name: '가상 라', position: '선임', jobTitle: null, profileImage: null, workStatus: 'active' });
|
||||
const summary = calculateSummary(data);
|
||||
const rows = selectMajorOrganizationTotals(data, summary);
|
||||
assert.deepEqual(rows.map(row => [row.organization.id, row.scope, row.count]), [['root', 'direct', 1], ['team', 'inclusive', 3], ['empty', 'inclusive', 0]]);
|
||||
assert.equal(rows.reduce((sum, row) => sum + row.count, 0), summary.raw.total);
|
||||
assert.equal(rows.some(row => row.organization.id === 'cell'), false);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { normalizeOrgChartDataset } from './normalize.js';
|
||||
import type { Employee, OrgChartDataset, OrgChartSummary, Organization, WorkStatus } from './types.js';
|
||||
|
||||
/** Internal content selectors: these do not expand the package's public data API. */
|
||||
function uniqueEmployees(data: OrgChartDataset): Employee[] {
|
||||
const seen = new Set<string>();
|
||||
return data.employees.filter(employee => {
|
||||
if (seen.has(employee.id)) return false;
|
||||
seen.add(employee.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Literal unique IDs in their primary organization, including leaders and all statuses.
|
||||
* Inclusive organization counts stay in Summary; they must never be summed as a subtotal.
|
||||
* Map keys safely retain opaque organization IDs and position names such as __proto__.
|
||||
*/
|
||||
export function selectOrganizationPositionCounts(data: OrgChartDataset): ReadonlyMap<string, ReadonlyMap<string, number>> {
|
||||
const counts = new Map<string, Map<string, number>>();
|
||||
for (const organization of data.organizations) counts.set(organization.id, new Map());
|
||||
for (const employee of uniqueEmployees(data)) {
|
||||
const row = counts.get(employee.organizationId);
|
||||
if (row) row.set(employee.position, (row.get(employee.position) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function selectAffiliationSummary(data: OrgChartDataset, summary: OrgChartSummary) {
|
||||
// Include all declared affiliations, including zero, without recounting employees.
|
||||
return data.affiliations.map(affiliation => ({
|
||||
affiliation,
|
||||
count: summary.raw.byAffiliation[affiliation.id] ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface MajorOrganizationTotal {
|
||||
organization: Organization;
|
||||
/** 'direct' for a root's own members (e.g. its leader); 'inclusive' for a major organization. */
|
||||
scope: 'direct' | 'inclusive';
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Compact totals for the read-only composition: each root's direct members, then
|
||||
* each major (root child) organization including its cells/teams. Primary
|
||||
* membership is single, so the rows are disjoint and add up to the raw total. */
|
||||
export function selectMajorOrganizationTotals(data: OrgChartDataset, summary: OrgChartSummary): MajorOrganizationTotal[] {
|
||||
const rows: MajorOrganizationTotal[] = [];
|
||||
for (const root of normalizeOrgChartDataset(data).roots) {
|
||||
const counts = summary.raw.byOrganization[root.organization.id];
|
||||
if (!root.children.length) {
|
||||
rows.push({ organization: root.organization, scope: 'inclusive', count: counts?.inclusive ?? 0 });
|
||||
continue;
|
||||
}
|
||||
if (counts?.direct) rows.push({ organization: root.organization, scope: 'direct', count: counts.direct });
|
||||
for (const child of root.children) {
|
||||
rows.push({ organization: child.organization, scope: 'inclusive', count: summary.raw.byOrganization[child.organization.id]?.inclusive ?? 0 });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export interface StatusContentGroup {
|
||||
status: Extract<WorkStatus, 'dispatched' | 'onLeave'>;
|
||||
employees: readonly Employee[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function selectStatusGroups(data: OrgChartDataset, summary: OrgChartSummary): StatusContentGroup[] {
|
||||
const employees = uniqueEmployees(data);
|
||||
const groups: StatusContentGroup[] = [];
|
||||
if (data.presentation?.groups.showDispatched !== false) {
|
||||
groups.push({ status: 'dispatched', employees: employees.filter(employee => employee.workStatus === 'dispatched'), count: summary.raw.byStatus.dispatched });
|
||||
}
|
||||
if (data.presentation?.groups.showOnLeave !== false) {
|
||||
groups.push({ status: 'onLeave', employees: employees.filter(employee => employee.workStatus === 'onLeave'), count: summary.raw.byStatus.onLeave });
|
||||
}
|
||||
// Cards retain original Employee references. A second display never changes Summary.
|
||||
return groups;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
calculateSummary, JsonOrgChartDataSource, normalizeOrgChartDataset, OrgChartSourceError,
|
||||
snapshotOrgChartDataset, validateOrgChartDataset, type ApprovedAggregationPolicy, type OrgChartDataset,
|
||||
} from './index.js';
|
||||
|
||||
function fixture(): OrgChartDataset {
|
||||
return {
|
||||
schemaVersion: '0.1-draft',
|
||||
organizations: [
|
||||
{ id: 'root', parentId: null, name: '가상 본부', type: 'root', leaderId: 'a', displayOrder: 0 },
|
||||
{ id: 'team-a', parentId: 'root', name: '가상 기획팀', type: 'team', leaderId: 'b', displayOrder: 2 },
|
||||
{ id: 'team-b', parentId: 'root', name: '가상 운영팀', type: 'team', leaderId: null, displayOrder: 1 },
|
||||
],
|
||||
employees: [
|
||||
{ id: 'a', organizationId: 'root', affiliationId: 'aff-a', name: '가상직원 가', position: '대표', jobTitle: '본부장', profileImage: null, workStatus: 'active' },
|
||||
{ id: 'b', organizationId: 'team-a', affiliationId: 'aff-a', name: '가상직원 나', position: '책임', jobTitle: '팀장', profileImage: null, workStatus: 'active' },
|
||||
{ id: 'c', organizationId: 'team-a', affiliationId: 'aff-b', name: '가상직원 다', position: '선임', jobTitle: null, profileImage: './profiles/virtual.png', workStatus: 'dispatched' },
|
||||
{ id: 'd', organizationId: 'team-b', affiliationId: 'aff-b', name: '가상직원 라', position: '선임', jobTitle: null, profileImage: 'https://example.invalid/virtual.png', workStatus: 'onLeave' },
|
||||
],
|
||||
affiliations: [{ id: 'aff-a', name: '가상 관계사 A' }, { id: 'aff-b', name: '가상 관계사 B' }],
|
||||
metadata: { asOfDate: '2026-10-12', dataVersion: 'test-1' },
|
||||
};
|
||||
}
|
||||
function codes(input: unknown): string[] {
|
||||
const result = validateOrgChartDataset(input);
|
||||
assert.equal(result.valid, false);
|
||||
return result.valid ? [] : result.issues.map(issue => issue.code);
|
||||
}
|
||||
const policy: ApprovedAggregationPolicy = {
|
||||
policyVersion: 'approved-test-1', includedStatuses: ['active', 'dispatched'], activeStatuses: ['active'],
|
||||
includeLeaders: true, membershipMode: 'primaryOnly',
|
||||
};
|
||||
|
||||
test('minimal v0.1-draft dataset validates and owns an immutable copy', () => {
|
||||
const original = fixture();
|
||||
const result = validateOrgChartDataset(original);
|
||||
assert.equal(result.valid, true);
|
||||
if (!result.valid) return;
|
||||
original.organizations[0]!.name = 'Changed';
|
||||
assert.equal(result.data.organizations[0]!.name, '가상 본부');
|
||||
assert.ok(Object.isFrozen(result.data.employees[0]));
|
||||
});
|
||||
test('version is required at top level; compatibility metadata mirror must agree', () => {
|
||||
const original = fixture() as unknown as Record<string, unknown>;
|
||||
delete original.schemaVersion;
|
||||
assert.ok(codes(original).includes('SCHEMA_VERSION'));
|
||||
const incompatible = fixture();
|
||||
(incompatible.metadata as unknown as Record<string, unknown>).schemaVersion = 'future';
|
||||
assert.ok(codes(incompatible).includes('SCHEMA_VERSION'));
|
||||
});
|
||||
test('required fields, nullability, whitespace and unknown fields are rejected', () => {
|
||||
const original = fixture();
|
||||
original.organizations[0]!.name = ' ';
|
||||
original.employees[0]!.position = null as unknown as string;
|
||||
(original.organizations[1] as unknown as Record<string, unknown>).nodeTemplate = '<script>alert(1)</script>';
|
||||
assert.ok(codes(original).includes('REQUIRED_VALUE'));
|
||||
assert.ok(codes(original).includes('UNKNOWN_FIELD'));
|
||||
});
|
||||
test('dates are real calendar dates and timestamps require valid clock/timezone', () => {
|
||||
for (const date of ['2026-02-30', '2026-13-01', '2026-1-1']) {
|
||||
const original = fixture(); original.metadata.asOfDate = date;
|
||||
assert.ok(codes(original).includes('REQUIRED_VALUE'));
|
||||
}
|
||||
const original = fixture(); original.metadata.updatedAt = '2026-10-12T24:00:00Z';
|
||||
assert.ok(codes(original).includes('REQUIRED_VALUE'));
|
||||
original.metadata.updatedAt = '2026-10-12T11:23:45+09:00';
|
||||
assert.equal(validateOrgChartDataset(original).valid, true);
|
||||
});
|
||||
test('zero employees is valid; zero organizations is a data error', () => {
|
||||
const original = fixture(); original.employees = [];
|
||||
original.organizations.forEach(org => { org.leaderId = null; });
|
||||
assert.equal(validateOrgChartDataset(original).valid, true);
|
||||
original.organizations = [];
|
||||
assert.ok(codes(original).includes('EMPTY_ORGANIZATIONS'));
|
||||
});
|
||||
test('duplicate collection IDs never overwrite an earlier item', () => {
|
||||
const original = fixture(); original.employees.push({ ...original.employees[0]! });
|
||||
assert.ok(codes(original).includes('DUPLICATE_ID'));
|
||||
});
|
||||
test('employee, parent, affiliation and leader references are checked', () => {
|
||||
const original = fixture();
|
||||
original.employees[0]!.organizationId = 'missing';
|
||||
original.employees[1]!.affiliationId = 'missing';
|
||||
original.organizations[2]!.parentId = 'missing';
|
||||
original.organizations[2]!.leaderId = 'missing';
|
||||
const failures = codes(original);
|
||||
for (const code of ['ORGANIZATION_REFERENCE', 'AFFILIATION_REFERENCE', 'PARENT_REFERENCE', 'LEADER_REFERENCE']) assert.ok(failures.includes(code));
|
||||
});
|
||||
test('leader cross-organization membership requires explicit business approval', () => {
|
||||
const original = fixture(); original.organizations[2]!.leaderId = 'b';
|
||||
assert.ok(codes(original).includes('LEADER_APPROVAL_REQUIRED'));
|
||||
});
|
||||
test('self-parent and multi-level cycles are rejected independently of sorting', () => {
|
||||
const original = fixture(); original.organizations[0]!.parentId = 'team-a';
|
||||
assert.ok(codes(original).includes('HIERARCHY_CYCLE'));
|
||||
original.organizations[0]!.parentId = 'root';
|
||||
assert.ok(codes(original).includes('HIERARCHY_CYCLE'));
|
||||
});
|
||||
test('unknown vocabularies are rejected without implicit status mapping', () => {
|
||||
const original = fixture(); original.employees[0]!.workStatus = 'retired' as never;
|
||||
original.organizations[0]!.type = 'unknown' as never;
|
||||
assert.ok(codes(original).includes('INVALID_STATUS'));
|
||||
assert.ok(codes(original).includes('INVALID_TYPE'));
|
||||
});
|
||||
test('relation endpoints, self-relations and duplicate directed relations are checked', () => {
|
||||
const original = fixture();
|
||||
original.relations = [
|
||||
{ id: 'r1', sourceOrganizationId: 'team-a', targetOrganizationId: 'team-b', type: 'support' },
|
||||
{ id: 'r2', sourceOrganizationId: 'team-a', targetOrganizationId: 'team-b', type: 'support' },
|
||||
{ id: 'r3', sourceOrganizationId: 'missing', targetOrganizationId: 'missing', type: 'support' },
|
||||
];
|
||||
assert.ok(codes(original).includes('INVALID_RELATION'));
|
||||
assert.ok(codes(original).includes('RELATION_REFERENCE'));
|
||||
});
|
||||
test('markup, executable URLs and identifier control characters are rejected', () => {
|
||||
for (const image of ['javascript:alert(1)', 'data:image/svg+xml,<svg/>', '//host.invalid/x', 'file:///x', 'https://user:password@example.invalid/x', 'images\\x.png']) {
|
||||
const original = fixture(); original.employees[0]!.profileImage = image;
|
||||
assert.ok(codes(original).includes('UNSAFE_INPUT'));
|
||||
}
|
||||
const original = fixture(); original.employees[0]!.name = '<img src=x onerror=alert(1)>';
|
||||
original.organizations[1]!.id += '\u202e';
|
||||
assert.ok(codes(original).includes('UNSAFE_INPUT'));
|
||||
});
|
||||
test('normalization preserves hierarchy, forest roots and deterministic presentation order', () => {
|
||||
const original = fixture();
|
||||
original.organizations.push({ id: 'other-root', parentId: null, name: '추가 본부', type: 'root', leaderId: null, displayOrder: 1 });
|
||||
const natural = normalizeOrgChartDataset(original);
|
||||
assert.deepEqual(natural.roots.map(node => node.organization.id), ['root', 'other-root']);
|
||||
assert.deepEqual(natural.roots[0]!.children.map(node => node.organization.id), ['team-b', 'team-a']);
|
||||
original.presentation = {
|
||||
title: '가상 조직도', description: null, hubText: null,
|
||||
groups: { showEmployees: false, showSummary: true, showDispatched: false, showOnLeave: false, showAffiliations: false },
|
||||
organizationOrder: ['team-a'],
|
||||
};
|
||||
assert.equal(validateOrgChartDataset(original).valid, true);
|
||||
assert.deepEqual(normalizeOrgChartDataset(original).roots[0]!.children.map(node => node.organization.id), ['team-a', 'team-b']);
|
||||
original.presentation.organizationOrder.push('missing');
|
||||
assert.ok(codes(original).includes('PRESENTATION_REFERENCE'));
|
||||
});
|
||||
test('raw summary uses unique IDs, primary membership and position rather than job title', () => {
|
||||
const original = fixture(); const summary = calculateSummary(original);
|
||||
assert.equal(summary.raw.total, 4);
|
||||
assert.deepEqual(summary.raw.byOrganization.root, { direct: 1, inclusive: 4 });
|
||||
assert.deepEqual(summary.raw.byOrganization['team-a'], { direct: 2, inclusive: 2 });
|
||||
assert.equal(summary.raw.byPosition['선임'], 2);
|
||||
assert.equal(summary.raw.byPosition['팀장'], undefined);
|
||||
assert.equal(summary.raw.byAffiliation['aff-b'], 2);
|
||||
assert.deepEqual(summary.raw.byStatus, { active: 2, dispatched: 1, onLeave: 1 });
|
||||
assert.deepEqual(summary.aggregation, { status: 'unavailable', reason: 'policyRequired' });
|
||||
});
|
||||
test('approved filtering changes business counts; raw counts and hidden groups remain unchanged', () => {
|
||||
const original = fixture(); const result = calculateSummary(original, policy);
|
||||
assert.equal(result.raw.total, 4);
|
||||
assert.equal(result.aggregation.status, 'ready');
|
||||
if (result.aggregation.status === 'ready') {
|
||||
assert.equal(result.aggregation.counts.total, 3);
|
||||
assert.equal(result.aggregation.counts.byOrganization.root!.inclusive, 3);
|
||||
assert.equal(result.aggregation.counts.byStatus.onLeave, 0);
|
||||
}
|
||||
original.presentation = {
|
||||
title: '가상 조직도', description: null, hubText: '설명 콘텐츠',
|
||||
groups: { showEmployees: false, showSummary: false, showDispatched: false, showOnLeave: false, showAffiliations: false },
|
||||
organizationOrder: ['team-b'],
|
||||
};
|
||||
original.relations = [{ id: 'rel-a', sourceOrganizationId: 'team-a', targetOrganizationId: 'team-b', type: 'cooperation' }];
|
||||
assert.deepEqual(calculateSummary(original, policy), result);
|
||||
original.employees[2]!.organizationId = 'team-b';
|
||||
original.employees[2]!.workStatus = 'active';
|
||||
original.employees[2]!.affiliationId = 'aff-a';
|
||||
const moved = calculateSummary(original);
|
||||
assert.equal(moved.raw.byOrganization['team-a']!.direct, 1);
|
||||
assert.equal(moved.raw.byOrganization['team-b']!.direct, 2);
|
||||
assert.equal(moved.raw.byStatus.active, 3);
|
||||
assert.equal(moved.raw.byAffiliation['aff-a'], 3);
|
||||
});
|
||||
test('ambiguous or invalid business policy stays unavailable', () => {
|
||||
for (const invalid of [
|
||||
{ ...policy, includeLeaders: false }, { ...policy, membershipMode: 'uniqueEmployee' },
|
||||
{ ...policy, policyVersion: '' }, { ...policy, includedStatuses: ['active', 'active'] },
|
||||
{ ...policy, includedStatuses: ['dispatched'] },
|
||||
]) {
|
||||
const result = calculateSummary(fixture(), invalid as ApprovedAggregationPolicy);
|
||||
assert.deepEqual(result.aggregation, { status: 'unavailable', reason: 'unsupportedPolicy' });
|
||||
}
|
||||
});
|
||||
test('prototype-like opaque IDs and positions cannot corrupt summary records', () => {
|
||||
const original = fixture(); original.employees[0]!.position = '__proto__';
|
||||
assert.equal(calculateSummary(original).raw.byPosition['__proto__'], 1);
|
||||
});
|
||||
test('source captures inline input once and returns owned copies', async () => {
|
||||
const original = fixture(); const source = new JsonOrgChartDataSource(original);
|
||||
original.metadata.dataVersion = 'consumer-mutated';
|
||||
const first = await source.load() as OrgChartDataset;
|
||||
assert.equal(first.metadata.dataVersion, 'test-1');
|
||||
assert.notEqual(first, await source.load());
|
||||
assert.ok(Object.isFrozen(first));
|
||||
const snapshot = snapshotOrgChartDataset(original);
|
||||
assert.notEqual(snapshot, original);
|
||||
});
|
||||
test('URL source passes cancellation signal and captures the response', async () => {
|
||||
const controller = new AbortController(); let receivedSignal: AbortSignal | null | undefined;
|
||||
const source = new JsonOrgChartDataSource('./virtual.json', { fetch: (async (_url, options) => {
|
||||
receivedSignal = options?.signal;
|
||||
return new Response(JSON.stringify(fixture()), { status: 200 });
|
||||
}) as typeof fetch });
|
||||
assert.equal(validateOrgChartDataset(await source.load({ signal: controller.signal })).valid, true);
|
||||
assert.equal(receivedSignal, controller.signal);
|
||||
});
|
||||
test('default network source loads with a receiver-sensitive browser-native fetch', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async function(this: unknown) {
|
||||
if (this !== globalThis) throw new TypeError('Illegal invocation');
|
||||
return new Response(JSON.stringify(fixture()), { status: 200 });
|
||||
} as typeof fetch;
|
||||
try {
|
||||
const source = new JsonOrgChartDataSource('./virtual.json');
|
||||
assert.equal(validateOrgChartDataset(await source.load()).valid, true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
test('source errors distinguish HTTP, parsing, unsafe URL and abort without leaking upstream text', async () => {
|
||||
assert.throws(() => new JsonOrgChartDataSource('javascript:alert(1)'), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_URL');
|
||||
const failed = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => { throw new Error('PRIVATE_SERVER_ERROR'); }) as typeof fetch });
|
||||
await assert.rejects(failed.load(), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_LOAD' && !error.message.includes('PRIVATE'));
|
||||
const http = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => new Response('private', { status: 500 })) as typeof fetch });
|
||||
await assert.rejects(http.load(), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_LOAD');
|
||||
const parse = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => new Response('{invalid')) as typeof fetch });
|
||||
await assert.rejects(parse.load(), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_PARSE');
|
||||
const controller = new AbortController(); controller.abort();
|
||||
await assert.rejects(failed.load({ signal: controller.signal }), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_ABORT');
|
||||
});
|
||||
test('late ignored abort response cannot become a successful source result', async () => {
|
||||
const controller = new AbortController();
|
||||
const source = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => {
|
||||
controller.abort(); return new Response(JSON.stringify(fixture()));
|
||||
}) as typeof fetch });
|
||||
await assert.rejects(source.load({ signal: controller.signal }), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_ABORT');
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './types.js';
|
||||
export { validateOrgChartDataset, isSafeImageUrl } from './validate.js';
|
||||
export { normalizeOrgChartDataset } from './normalize.js';
|
||||
export { calculateSummary } from './summary.js';
|
||||
export { snapshotOrgChartDataset } from './snapshot.js';
|
||||
export { JsonOrgChartDataSource, OrgChartSourceError, type OrgChartSourceErrorCode, type JsonOrgChartDataSourceOptions } from './source.js';
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Employee, NormalizedOrgChartDataset, OrganizationTreeNode, OrgChartDataset } from './types.js';
|
||||
|
||||
/** Pure derivation for validated datasets. Multiple roots remain a forest. */
|
||||
export function normalizeOrgChartDataset(data: OrgChartDataset): NormalizedOrgChartDataset {
|
||||
const organizationsById = new Map(data.organizations.map(org => [org.id, org]));
|
||||
const employeesByOrganization = new Map<string, Employee[]>(data.organizations.map(org => [org.id, []]));
|
||||
for (const employee of data.employees) employeesByOrganization.get(employee.organizationId)?.push(employee);
|
||||
const nodes = new Map<string, OrganizationTreeNode>(data.organizations.map(org => [org.id, { organization: org, children: [] }]));
|
||||
const roots: OrganizationTreeNode[] = [];
|
||||
for (const node of nodes.values()) {
|
||||
if (node.organization.parentId === null) roots.push(node);
|
||||
else nodes.get(node.organization.parentId)?.children.push(node);
|
||||
}
|
||||
const order = new Map((data.presentation?.organizationOrder ?? []).map((id, index) => [id, index]));
|
||||
const compare = (a: OrganizationTreeNode, b: OrganizationTreeNode) => {
|
||||
const aOrder = order.get(a.organization.id) ?? Infinity;
|
||||
const bOrder = order.get(b.organization.id) ?? Infinity;
|
||||
if (aOrder !== bOrder) return aOrder < bOrder ? -1 : 1;
|
||||
const difference = a.organization.displayOrder - b.organization.displayOrder;
|
||||
if (difference) return difference;
|
||||
// Code-unit order is deterministic across environments and independent of locale.
|
||||
return a.organization.id < b.organization.id ? -1 : a.organization.id > b.organization.id ? 1 : 0;
|
||||
};
|
||||
roots.sort(compare);
|
||||
for (const node of nodes.values()) node.children.sort(compare);
|
||||
return { roots, organizationsById, employeesByOrganization };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Own a JSON-like input at the call boundary; never retain consumer objects. */
|
||||
export function snapshotOrgChartDataset<T>(input: T): T {
|
||||
const copy = structuredClone(input);
|
||||
const pending: unknown[] = [copy];
|
||||
const seen = new WeakSet<object>();
|
||||
while (pending.length) {
|
||||
const current = pending.pop();
|
||||
if (current === null || typeof current !== 'object' || seen.has(current)) continue;
|
||||
seen.add(current);
|
||||
for (const child of Object.values(current)) pending.push(child);
|
||||
Object.freeze(current);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { snapshotOrgChartDataset } from './snapshot.js';
|
||||
import type { OrgChartDataSource } from './types.js';
|
||||
|
||||
export type OrgChartSourceErrorCode = 'SOURCE_LOAD' | 'SOURCE_PARSE' | 'SOURCE_ABORT' | 'SOURCE_URL' | 'SOURCE_SNAPSHOT';
|
||||
export class OrgChartSourceError extends Error {
|
||||
readonly path = 'dataSource';
|
||||
constructor(readonly code: OrgChartSourceErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = 'OrgChartSourceError';
|
||||
}
|
||||
}
|
||||
export interface JsonOrgChartDataSourceOptions { fetch?: typeof globalThis.fetch }
|
||||
/** Inline JSON is captured at construction; URL sources load a fresh snapshot on each call. */
|
||||
export class JsonOrgChartDataSource implements OrgChartDataSource {
|
||||
private readonly inline: unknown;
|
||||
private readonly url: string | undefined;
|
||||
private readonly fetcher: typeof globalThis.fetch | undefined;
|
||||
constructor(input: unknown, options: JsonOrgChartDataSourceOptions = {}) {
|
||||
// Browser-native fetch may require the global Window receiver. Retaining it as
|
||||
// an instance property must not accidentally call it with this source as `this`.
|
||||
this.fetcher = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
||||
if (typeof input === 'string' || input instanceof URL) {
|
||||
const url = String(input);
|
||||
if (!safeSourceUrl(url)) throw new OrgChartSourceError('SOURCE_URL', 'Data source URL must use HTTP(S) or a relative JSON path.');
|
||||
this.url = url;
|
||||
} else {
|
||||
try { this.inline = snapshotOrgChartDataset(input); }
|
||||
catch { throw new OrgChartSourceError('SOURCE_SNAPSHOT', 'Data source input must be cloneable JSON.'); }
|
||||
}
|
||||
}
|
||||
async load(options: { signal?: AbortSignal } = {}): Promise<unknown> {
|
||||
const signal = options.signal;
|
||||
const aborted = () => { if (signal?.aborted) throw new OrgChartSourceError('SOURCE_ABORT', 'Data source request was cancelled.'); };
|
||||
aborted();
|
||||
if (this.url === undefined) return snapshotOrgChartDataset(this.inline);
|
||||
if (!this.fetcher) throw new OrgChartSourceError('SOURCE_LOAD', 'No fetch implementation is available.');
|
||||
let response: Response;
|
||||
try { response = await this.fetcher(this.url, { signal }); }
|
||||
catch {
|
||||
aborted();
|
||||
throw new OrgChartSourceError('SOURCE_LOAD', 'Data source could not be loaded.');
|
||||
}
|
||||
aborted();
|
||||
if (!response.ok) throw new OrgChartSourceError('SOURCE_LOAD', 'Data source returned an unsuccessful response.');
|
||||
let payload: unknown;
|
||||
try { payload = await response.json(); }
|
||||
catch {
|
||||
aborted();
|
||||
throw new OrgChartSourceError('SOURCE_PARSE', 'Data source response is not valid JSON.');
|
||||
}
|
||||
aborted();
|
||||
try { return snapshotOrgChartDataset(payload); }
|
||||
catch { throw new OrgChartSourceError('SOURCE_SNAPSHOT', 'Data source response must be cloneable JSON.'); }
|
||||
}
|
||||
}
|
||||
function safeSourceUrl(value: string): boolean {
|
||||
if (!value.trim() || /[\s\\<>\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/.test(value) || value.startsWith('//')) return false;
|
||||
if (!/^[a-z][a-z\d+.-]*:/i.test(value)) return !/%(?:0[0-9a-f]|1[0-9a-f]|7f|5c)/i.test(value);
|
||||
try { const parsed = new URL(value); return ['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password; }
|
||||
catch { return false; }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { normalizeOrgChartDataset } from './normalize.js';
|
||||
import type { ApprovedAggregationPolicy, Employee, OrgChartDataset, OrgChartSummary, SummaryCounts, WorkStatus } from './types.js';
|
||||
|
||||
const statuses: WorkStatus[] = ['active', 'dispatched', 'onLeave'];
|
||||
function supportedPolicy(policy: ApprovedAggregationPolicy): boolean {
|
||||
const validStatuses = (values: unknown): values is WorkStatus[] => Array.isArray(values)
|
||||
&& values.every(value => statuses.includes(value)) && new Set(values).size === values.length;
|
||||
return !!policy && typeof policy.policyVersion === 'string' && !!policy.policyVersion.trim()
|
||||
&& !/[\u0000-\u001f<>]/.test(policy.policyVersion)
|
||||
&& validStatuses(policy.includedStatuses) && validStatuses(policy.activeStatuses)
|
||||
&& policy.activeStatuses.every(status => policy.includedStatuses.includes(status))
|
||||
&& policy.includeLeaders === true && policy.membershipMode === 'primaryOnly';
|
||||
}
|
||||
function count(data: OrgChartDataset, employees: readonly Employee[]): SummaryCounts {
|
||||
const counts: SummaryCounts = {
|
||||
total: 0,
|
||||
byOrganization: Object.create(null) as SummaryCounts['byOrganization'],
|
||||
byPosition: Object.create(null) as SummaryCounts['byPosition'],
|
||||
byAffiliation: Object.create(null) as SummaryCounts['byAffiliation'],
|
||||
byStatus: { active: 0, dispatched: 0, onLeave: 0 },
|
||||
};
|
||||
for (const org of data.organizations) counts.byOrganization[org.id] = { direct: 0, inclusive: 0 };
|
||||
for (const affiliation of data.affiliations) counts.byAffiliation[affiliation.id] = 0;
|
||||
const seen = new Set<string>();
|
||||
for (const employee of employees) {
|
||||
if (seen.has(employee.id)) continue;
|
||||
seen.add(employee.id);
|
||||
counts.total += 1;
|
||||
counts.byOrganization[employee.organizationId]!.direct += 1;
|
||||
counts.byPosition[employee.position] = (counts.byPosition[employee.position] ?? 0) + 1;
|
||||
counts.byAffiliation[employee.affiliationId] = (counts.byAffiliation[employee.affiliationId] ?? 0) + 1;
|
||||
counts.byStatus[employee.workStatus] += 1;
|
||||
}
|
||||
// Single primary membership makes child totals disjoint; compute a postorder iteratively.
|
||||
const stack = [...normalizeOrgChartDataset(data).roots];
|
||||
const traversal = [];
|
||||
while (stack.length) {
|
||||
const node = stack.pop()!;
|
||||
traversal.push(node);
|
||||
stack.push(...node.children);
|
||||
}
|
||||
for (let index = traversal.length - 1; index >= 0; index--) {
|
||||
const node = traversal[index]!;
|
||||
counts.byOrganization[node.organization.id]!.inclusive = counts.byOrganization[node.organization.id]!.direct
|
||||
+ node.children.reduce((sum, child) => sum + counts.byOrganization[child.organization.id]!.inclusive, 0);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
/** Raw counts are literal unique employee IDs, independent of presentation or business totals. */
|
||||
export function calculateSummary(data: OrgChartDataset, policy?: ApprovedAggregationPolicy): OrgChartSummary {
|
||||
const raw = count(data, data.employees);
|
||||
if (!policy) return { raw, aggregation: { status: 'unavailable', reason: 'policyRequired' } };
|
||||
if (!supportedPolicy(policy)) return { raw, aggregation: { status: 'unavailable', reason: 'unsupportedPolicy' } };
|
||||
const selected = data.employees.filter(employee => policy.includedStatuses.includes(employee.workStatus));
|
||||
return { raw, aggregation: { status: 'ready', policyVersion: policy.policyVersion, counts: count(data, selected) } };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
export const SCHEMA_VERSION = '0.1-draft' as const;
|
||||
export type ID = string;
|
||||
export type OrganizationType = 'root' | 'division' | 'office' | 'div' | 'cell' | 'team';
|
||||
export type WorkStatus = 'active' | 'dispatched' | 'onLeave';
|
||||
export type RelationType = 'cooperation' | 'support';
|
||||
|
||||
export interface Organization {
|
||||
id: ID;
|
||||
parentId: ID | null;
|
||||
name: string;
|
||||
type: OrganizationType;
|
||||
leaderId: ID | null;
|
||||
displayOrder: number;
|
||||
}
|
||||
export interface Employee {
|
||||
id: ID;
|
||||
organizationId: ID;
|
||||
affiliationId: ID;
|
||||
name: string;
|
||||
position: string;
|
||||
jobTitle: string | null;
|
||||
profileImage: string | null;
|
||||
workStatus: WorkStatus;
|
||||
}
|
||||
export interface Affiliation { id: ID; name: string }
|
||||
export interface OrganizationRelation {
|
||||
id: ID;
|
||||
sourceOrganizationId: ID;
|
||||
targetOrganizationId: ID;
|
||||
type: RelationType;
|
||||
}
|
||||
export interface PresentationConfig {
|
||||
title: string;
|
||||
description: string | null;
|
||||
hubText: string | null;
|
||||
groups: {
|
||||
showEmployees: boolean;
|
||||
showSummary: boolean;
|
||||
showDispatched: boolean;
|
||||
showOnLeave: boolean;
|
||||
showAffiliations: boolean;
|
||||
};
|
||||
organizationOrder: ID[];
|
||||
}
|
||||
export interface Metadata {
|
||||
/** Compatibility mirror; the top-level schemaVersion is authoritative. */
|
||||
schemaVersion?: typeof SCHEMA_VERSION;
|
||||
asOfDate: string;
|
||||
dataVersion: string;
|
||||
updatedAt?: string | null;
|
||||
updateNote?: string | null;
|
||||
}
|
||||
export interface OrgChartDataset {
|
||||
schemaVersion: typeof SCHEMA_VERSION;
|
||||
organizations: Organization[];
|
||||
employees: Employee[];
|
||||
affiliations: Affiliation[];
|
||||
relations?: OrganizationRelation[];
|
||||
presentation?: PresentationConfig;
|
||||
metadata: Metadata;
|
||||
}
|
||||
export interface OrgChartDataSource {
|
||||
load(options?: { signal?: AbortSignal }): Promise<unknown>;
|
||||
}
|
||||
export interface ValidationIssue { code: string; path: string; message: string }
|
||||
export type ValidationResult =
|
||||
| { valid: true; data: OrgChartDataset }
|
||||
| { valid: false; issues: ValidationIssue[] };
|
||||
export interface OrganizationTreeNode {
|
||||
organization: Organization;
|
||||
children: OrganizationTreeNode[];
|
||||
}
|
||||
export interface NormalizedOrgChartDataset {
|
||||
roots: OrganizationTreeNode[];
|
||||
organizationsById: ReadonlyMap<ID, Organization>;
|
||||
employeesByOrganization: ReadonlyMap<ID, readonly Employee[]>;
|
||||
}
|
||||
export interface SummaryCounts {
|
||||
total: number;
|
||||
byOrganization: Record<ID, { direct: number; inclusive: number }>;
|
||||
byPosition: Record<string, number>;
|
||||
byAffiliation: Record<ID, number>;
|
||||
byStatus: Record<WorkStatus, number>;
|
||||
}
|
||||
export interface ApprovedAggregationPolicy {
|
||||
policyVersion: string;
|
||||
includedStatuses: WorkStatus[];
|
||||
activeStatuses: WorkStatus[];
|
||||
includeLeaders: boolean;
|
||||
membershipMode: 'primaryOnly' | 'uniqueEmployee';
|
||||
}
|
||||
export type AggregationPolicy = ApprovedAggregationPolicy;
|
||||
export type AggregationResult =
|
||||
| { status: 'unavailable'; reason: 'policyRequired' | 'unsupportedPolicy' }
|
||||
| { status: 'ready'; policyVersion: string; counts: SummaryCounts };
|
||||
export interface OrgChartSummary { raw: SummaryCounts; aggregation: AggregationResult }
|
||||
@@ -0,0 +1,145 @@
|
||||
import { SCHEMA_VERSION, type OrgChartDataset, type ValidationIssue, type ValidationResult } from './types.js';
|
||||
import { snapshotOrgChartDataset } from './snapshot.js';
|
||||
|
||||
const organizationTypes = ['root', 'division', 'office', 'div', 'cell', 'team'];
|
||||
const statuses = ['active', 'dispatched', 'onLeave'];
|
||||
const relationTypes = ['cooperation', 'support'];
|
||||
const unsafeText = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]|<\/?[a-z][^>]*>|(?:javascript|vbscript)\s*:/i;
|
||||
type RecordValue = Record<string, unknown>;
|
||||
function record(input: unknown): input is RecordValue {
|
||||
return !!input && typeof input === 'object' && !Array.isArray(input)
|
||||
&& (Object.getPrototypeOf(input) === Object.prototype || Object.getPrototypeOf(input) === null);
|
||||
}
|
||||
export function isSafeImageUrl(input: string): boolean {
|
||||
if (!input.trim() || unsafeText.test(input) || /\s|\\/.test(input)) return false;
|
||||
if (/^[a-z][a-z\d+.-]*:/i.test(input)) {
|
||||
try { const url = new URL(input); return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password; }
|
||||
catch { return false; }
|
||||
}
|
||||
// Protocol-relative URLs inherit network origins; require an explicit HTTP(S) URL instead.
|
||||
return !input.startsWith('//') && !/%(?:0[0-9a-f]|1[0-9a-f]|7f|5c)/i.test(input);
|
||||
}
|
||||
function validDate(value: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
const parsed = new Date(value);
|
||||
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
||||
}
|
||||
function validTimestamp(value: string): boolean {
|
||||
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2}))$/.exec(value);
|
||||
return !!match && validDate(match[1]!) && Number(match[2]) < 24 && Number(match[3]) < 60
|
||||
&& Number(match[4]) < 60 && (!match[6] || Number(match[6]) < 24) && (!match[7] || Number(match[7]) < 60)
|
||||
&& Number.isFinite(Date.parse(value));
|
||||
}
|
||||
|
||||
/** Strict contract validation. Messages contain no input values or server details. */
|
||||
export function validateOrgChartDataset(input: unknown): ValidationResult {
|
||||
const issues: ValidationIssue[] = [];
|
||||
const issue = (code: string, path: string, message: string) => issues.push({ code, path, message });
|
||||
const object = (value: unknown, path: string, keys: string[]): value is RecordValue => {
|
||||
if (!record(value)) { issue('REQUIRED_VALUE', path, 'Expected a plain JSON object.'); return false; }
|
||||
for (const key of Object.keys(value)) if (!keys.includes(key)) issue('UNKNOWN_FIELD', `${path}.${key}`, 'Field is not part of this contract.');
|
||||
return true;
|
||||
};
|
||||
const text = (value: unknown, path: string, nullable = false): boolean => {
|
||||
if (nullable && value === null) return true;
|
||||
if (typeof value !== 'string' || !value.trim()) { issue('REQUIRED_VALUE', path, 'Expected a non-empty text value.'); return false; }
|
||||
if (unsafeText.test(value)) { issue('UNSAFE_INPUT', path, 'Text contains unsupported executable markup or control characters.'); return false; }
|
||||
return true;
|
||||
};
|
||||
const choice = (value: unknown, path: string, allowed: string[], code: string) => {
|
||||
if (typeof value !== 'string' || !allowed.includes(value)) issue(code, path, 'Value is outside the supported contract vocabulary.');
|
||||
};
|
||||
const collection = (value: unknown, path: string, keys: string[], inspect: (item: RecordValue, path: string) => void, optional = false) => {
|
||||
if (optional && value === undefined) return;
|
||||
if (!Array.isArray(value)) { issue('REQUIRED_VALUE', path, 'Expected an array.'); return; }
|
||||
const ids = new Set<string>();
|
||||
value.forEach((item, index) => {
|
||||
const location = `${path}[${index}]`;
|
||||
if (!object(item, location, keys)) return;
|
||||
if (text(item.id, `${location}.id`)) {
|
||||
if (ids.has(item.id as string)) issue('DUPLICATE_ID', `${location}.id`, 'ID must be unique within its collection.');
|
||||
ids.add(item.id as string);
|
||||
}
|
||||
inspect(item, location);
|
||||
});
|
||||
};
|
||||
if (!object(input, '$', ['schemaVersion', 'organizations', 'employees', 'affiliations', 'relations', 'presentation', 'metadata'])) return { valid: false, issues };
|
||||
if (input.schemaVersion !== SCHEMA_VERSION) issue('SCHEMA_VERSION', '$.schemaVersion', 'Unsupported or missing schema version.');
|
||||
collection(input.organizations, '$.organizations', ['id', 'parentId', 'name', 'type', 'leaderId', 'displayOrder'], (org, path) => {
|
||||
text(org.parentId, `${path}.parentId`, true); text(org.name, `${path}.name`); text(org.leaderId, `${path}.leaderId`, true);
|
||||
choice(org.type, `${path}.type`, organizationTypes, 'INVALID_TYPE');
|
||||
if (!Number.isSafeInteger(org.displayOrder) || (org.displayOrder as number) < 0) issue('REQUIRED_VALUE', `${path}.displayOrder`, 'Expected a non-negative safe integer.');
|
||||
});
|
||||
if (Array.isArray(input.organizations) && input.organizations.length === 0) issue('EMPTY_ORGANIZATIONS', '$.organizations', 'At least one organization is required.');
|
||||
collection(input.employees, '$.employees', ['id', 'organizationId', 'affiliationId', 'name', 'position', 'jobTitle', 'profileImage', 'workStatus'], (employee, path) => {
|
||||
for (const key of ['organizationId', 'affiliationId', 'name', 'position']) text(employee[key], `${path}.${key}`);
|
||||
text(employee.jobTitle, `${path}.jobTitle`, true);
|
||||
if (text(employee.profileImage, `${path}.profileImage`, true) && typeof employee.profileImage === 'string' && !isSafeImageUrl(employee.profileImage)) issue('UNSAFE_INPUT', `${path}.profileImage`, 'Image URL must use HTTP(S) or a safe relative path.');
|
||||
choice(employee.workStatus, `${path}.workStatus`, statuses, 'INVALID_STATUS');
|
||||
});
|
||||
collection(input.affiliations, '$.affiliations', ['id', 'name'], (affiliation, path) => { text(affiliation.name, `${path}.name`); });
|
||||
collection(input.relations, '$.relations', ['id', 'sourceOrganizationId', 'targetOrganizationId', 'type'], (relation, path) => {
|
||||
text(relation.sourceOrganizationId, `${path}.sourceOrganizationId`); text(relation.targetOrganizationId, `${path}.targetOrganizationId`);
|
||||
choice(relation.type, `${path}.type`, relationTypes, 'INVALID_RELATION');
|
||||
}, true);
|
||||
if (object(input.metadata, '$.metadata', ['schemaVersion', 'asOfDate', 'dataVersion', 'updatedAt', 'updateNote'])) {
|
||||
const meta = input.metadata;
|
||||
if (meta.schemaVersion !== undefined && meta.schemaVersion !== SCHEMA_VERSION) issue('SCHEMA_VERSION', '$.metadata.schemaVersion', 'Compatibility schema version must match the top-level version.');
|
||||
if (text(meta.asOfDate, '$.metadata.asOfDate') && !validDate(meta.asOfDate as string)) issue('REQUIRED_VALUE', '$.metadata.asOfDate', 'Expected a real calendar date in YYYY-MM-DD format.');
|
||||
text(meta.dataVersion, '$.metadata.dataVersion');
|
||||
if (meta.updatedAt !== undefined && text(meta.updatedAt, '$.metadata.updatedAt', true) && typeof meta.updatedAt === 'string' && !validTimestamp(meta.updatedAt)) issue('REQUIRED_VALUE', '$.metadata.updatedAt', 'Expected an RFC 3339 timestamp with timezone.');
|
||||
if (meta.updateNote !== undefined) text(meta.updateNote, '$.metadata.updateNote', true);
|
||||
}
|
||||
if (input.presentation !== undefined && object(input.presentation, '$.presentation', ['title', 'description', 'hubText', 'groups', 'organizationOrder'])) {
|
||||
const presentation = input.presentation;
|
||||
text(presentation.title, '$.presentation.title'); text(presentation.description, '$.presentation.description', true); text(presentation.hubText, '$.presentation.hubText', true);
|
||||
const flags = ['showEmployees', 'showSummary', 'showDispatched', 'showOnLeave', 'showAffiliations'];
|
||||
if (object(presentation.groups, '$.presentation.groups', flags)) for (const flag of flags) if (typeof presentation.groups[flag] !== 'boolean') issue('REQUIRED_VALUE', `$.presentation.groups.${flag}`, 'Expected a boolean.');
|
||||
if (!Array.isArray(presentation.organizationOrder)) issue('REQUIRED_VALUE', '$.presentation.organizationOrder', 'Expected an array of organization IDs.');
|
||||
else presentation.organizationOrder.forEach((id, index) => text(id, `$.presentation.organizationOrder[${index}]`));
|
||||
}
|
||||
if (issues.length) return { valid: false, issues };
|
||||
const data = input as unknown as OrgChartDataset;
|
||||
const organizations = new Map(data.organizations.map(org => [org.id, org]));
|
||||
const employees = new Map(data.employees.map(employee => [employee.id, employee]));
|
||||
const affiliations = new Set(data.affiliations.map(affiliation => affiliation.id));
|
||||
data.organizations.forEach((org, index) => {
|
||||
if (org.parentId !== null && !organizations.has(org.parentId)) issue('PARENT_REFERENCE', `$.organizations[${index}].parentId`, 'Parent organization does not exist.');
|
||||
if (org.leaderId !== null) {
|
||||
const leader = employees.get(org.leaderId);
|
||||
if (!leader) issue('LEADER_REFERENCE', `$.organizations[${index}].leaderId`, 'Leader employee does not exist.');
|
||||
else if (leader.organizationId !== org.id) issue('LEADER_APPROVAL_REQUIRED', `$.organizations[${index}].leaderId`, 'Leader membership outside this organization requires an approved business policy.');
|
||||
}
|
||||
});
|
||||
data.employees.forEach((employee, index) => {
|
||||
if (!organizations.has(employee.organizationId)) issue('ORGANIZATION_REFERENCE', `$.employees[${index}].organizationId`, 'Employee organization does not exist.');
|
||||
if (!affiliations.has(employee.affiliationId)) issue('AFFILIATION_REFERENCE', `$.employees[${index}].affiliationId`, 'Employee affiliation does not exist.');
|
||||
});
|
||||
const checked = new Set<string>();
|
||||
for (const org of data.organizations) {
|
||||
if (checked.has(org.id)) continue;
|
||||
const chain = new Set<string>();
|
||||
let current: typeof org | undefined = org;
|
||||
while (current && !checked.has(current.id)) {
|
||||
if (chain.has(current.id)) { issue('HIERARCHY_CYCLE', '$.organizations', 'Organization parent references contain a cycle.'); break; }
|
||||
chain.add(current.id);
|
||||
current = current.parentId === null ? undefined : organizations.get(current.parentId);
|
||||
}
|
||||
for (const id of chain) checked.add(id);
|
||||
}
|
||||
const relationKeys = new Set<string>();
|
||||
data.relations?.forEach((relation, index) => {
|
||||
if (!organizations.has(relation.sourceOrganizationId) || !organizations.has(relation.targetOrganizationId)) issue('RELATION_REFERENCE', `$.relations[${index}]`, 'Relation endpoint organization does not exist.');
|
||||
const key = JSON.stringify([relation.sourceOrganizationId, relation.targetOrganizationId, relation.type]);
|
||||
if (relation.sourceOrganizationId === relation.targetOrganizationId || relationKeys.has(key)) issue('INVALID_RELATION', `$.relations[${index}]`, 'Self-relations and duplicate directed relations are unsupported.');
|
||||
relationKeys.add(key);
|
||||
});
|
||||
const orderIds = new Set<string>();
|
||||
data.presentation?.organizationOrder.forEach((id, index) => {
|
||||
if (!organizations.has(id) || orderIds.has(id)) issue('PRESENTATION_REFERENCE', `$.presentation.organizationOrder[${index}]`, 'Presentation order requires existing, unique organization IDs.');
|
||||
orderIds.add(id);
|
||||
});
|
||||
if (issues.length) return { valid: false, issues };
|
||||
try { return { valid: true, data: snapshotOrgChartDataset(data) }; }
|
||||
catch { return { valid: false, issues: [{ code: 'UNSAFE_INPUT', path: '$', message: 'Input must be a cloneable JSON dataset.' }] }; }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './data/index.js';
|
||||
export { createOrgChart } from './core/index.js';
|
||||
export type { OrgChartDisplayMode, OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartOrganizationLinks, OrgChartState } from './core/types.js';
|
||||
export { createOrgChartTree, createPositionSummary, createAffiliationSummary, createStatusSummary } from './components.js';
|
||||
export type { OrgChartSummaryComponentOptions } from './components.js';
|
||||
@@ -0,0 +1,68 @@
|
||||
/* Standalone OrgChart design defaults. No HM Theme dependency.
|
||||
Only the six organization header gradients were measured in Workdesk.
|
||||
Neutral surfaces, card styling, typography and geometry are independent
|
||||
design adaptations, not verified official Workdesk values.
|
||||
Public --hm-orgchart-* inputs are intentionally NOT declared here: host
|
||||
overrides must inherit through the Shadow DOM. Resolve defaults locally. */
|
||||
.hm-orgchart-shell {
|
||||
--hm-orgchart-resolved-bg-surface: var(--hm-orgchart-bg-surface, #ffffff);
|
||||
--hm-orgchart-resolved-bg-content: var(--hm-orgchart-bg-content, #f4f7fb);
|
||||
--hm-orgchart-resolved-bg-subtle: var(--hm-orgchart-bg-subtle, #e9edf3);
|
||||
--hm-orgchart-resolved-bg-brand: var(--hm-orgchart-bg-brand, linear-gradient(180deg, #45639f 0%, #809dd7 100%));
|
||||
--hm-orgchart-resolved-fg-primary: var(--hm-orgchart-fg-primary, #10243b);
|
||||
--hm-orgchart-resolved-fg-secondary: var(--hm-orgchart-fg-secondary, #596575);
|
||||
--hm-orgchart-resolved-stroke-default: var(--hm-orgchart-stroke-default, #bac7d7);
|
||||
--hm-orgchart-resolved-stroke-input-default: var(--hm-orgchart-stroke-input-default, #d3dce8);
|
||||
--hm-orgchart-resolved-stroke-input-focus: var(--hm-orgchart-stroke-input-focus, #45639f);
|
||||
--hm-orgchart-resolved-accent: var(--hm-orgchart-accent-color, #eef2fa);
|
||||
--hm-orgchart-resolved-on-accent: var(--hm-orgchart-on-accent, #224387);
|
||||
--hm-orgchart-resolved-action-primary: var(--hm-orgchart-action-primary, #45639f);
|
||||
--hm-orgchart-resolved-on-action-primary: var(--hm-orgchart-on-action-primary, #ffffff);
|
||||
--hm-orgchart-resolved-font-family: var(--hm-orgchart-font-family, 'Noto Sans KR', 'Malgun Gothic', Arial, sans-serif);
|
||||
--hm-orgchart-resolved-font-weight-bold: var(--hm-orgchart-font-weight-bold, 700);
|
||||
--hm-orgchart-resolved-font-weight-medium: var(--hm-orgchart-font-weight-medium, 500);
|
||||
--hm-orgchart-resolved-radius-container: var(--hm-orgchart-radius-container, 4px);
|
||||
--hm-orgchart-resolved-radius-control-md: var(--hm-orgchart-radius-control-md, 4px);
|
||||
--hm-orgchart-resolved-radius-pill: var(--hm-orgchart-radius-pill, 999px);
|
||||
|
||||
--hm-orgchart-resolved-spacing-xs: var(--hm-orgchart-spacing-xs, 4px);
|
||||
--hm-orgchart-resolved-spacing-sm: var(--hm-orgchart-spacing-sm, 8px);
|
||||
--hm-orgchart-resolved-spacing-md: var(--hm-orgchart-spacing-md, 12px);
|
||||
--hm-orgchart-resolved-spacing-lg: var(--hm-orgchart-spacing-lg, 16px);
|
||||
--hm-orgchart-resolved-spacing-xl: var(--hm-orgchart-spacing-xl, 20px);
|
||||
--hm-orgchart-resolved-spacing-xxl: var(--hm-orgchart-spacing-xxl, 24px);
|
||||
--hm-orgchart-resolved-border-width: var(--hm-orgchart-border-width, 1px);
|
||||
--hm-orgchart-resolved-border-top-width: var(--hm-orgchart-border-top-width, 3px);
|
||||
--hm-orgchart-resolved-font-size-title: var(--hm-orgchart-font-size-title, 24px);
|
||||
--hm-orgchart-resolved-font-size-base: var(--hm-orgchart-font-size-base, 13px);
|
||||
--hm-orgchart-resolved-font-size-person: var(--hm-orgchart-font-size-person, 13px);
|
||||
--hm-orgchart-resolved-font-size-meta: var(--hm-orgchart-font-size-meta, 10px);
|
||||
--hm-orgchart-resolved-card-width-employee: var(--hm-orgchart-card-width-employee, 114px);
|
||||
--hm-orgchart-resolved-card-width-leader: var(--hm-orgchart-card-width-leader, 154px);
|
||||
--hm-orgchart-resolved-card-width-top-leader: var(--hm-orgchart-card-width-top-leader, 155px);
|
||||
--hm-orgchart-resolved-card-height-employee: var(--hm-orgchart-card-height-employee, 54px);
|
||||
--hm-orgchart-resolved-card-height-leader: var(--hm-orgchart-card-height-leader, 72px);
|
||||
--hm-orgchart-resolved-card-height-top-leader: var(--hm-orgchart-card-height-top-leader, 78px);
|
||||
|
||||
--hm-orgchart-resolved-table-background: var(--hm-orgchart-table-background, var(--hm-orgchart-resolved-bg-surface));
|
||||
--hm-orgchart-resolved-table-text: var(--hm-orgchart-table-text, var(--hm-orgchart-resolved-fg-primary));
|
||||
--hm-orgchart-resolved-table-font-size: var(--hm-orgchart-table-font-size, 12px);
|
||||
--hm-orgchart-resolved-table-line-height: var(--hm-orgchart-table-line-height, 18px);
|
||||
--hm-orgchart-resolved-table-cell-padding-x: var(--hm-orgchart-table-cell-padding-x, 8px);
|
||||
--hm-orgchart-resolved-table-cell-padding-y: var(--hm-orgchart-table-cell-padding-y, 6px);
|
||||
--hm-orgchart-resolved-table-divider-color: var(--hm-orgchart-table-divider-color, var(--hm-orgchart-resolved-stroke-input-default));
|
||||
--hm-orgchart-resolved-table-divider-width: var(--hm-orgchart-table-divider-width, var(--hm-orgchart-resolved-border-width));
|
||||
--hm-orgchart-resolved-table-header-background: var(--hm-orgchart-table-header-background, #edf2f8);
|
||||
--hm-orgchart-resolved-table-header-text: var(--hm-orgchart-table-header-text, #224387);
|
||||
--hm-orgchart-resolved-table-header-font-size: var(--hm-orgchart-table-header-font-size, 12px);
|
||||
--hm-orgchart-resolved-table-header-font-weight: var(--hm-orgchart-table-header-font-weight, var(--hm-orgchart-resolved-font-weight-bold));
|
||||
--hm-orgchart-resolved-table-header-border-top: var(--hm-orgchart-table-header-border-top, var(--hm-orgchart-resolved-stroke-input-focus));
|
||||
--hm-orgchart-resolved-table-header-border-bottom: var(--hm-orgchart-table-header-border-bottom, var(--hm-orgchart-resolved-stroke-input-default));
|
||||
--hm-orgchart-resolved-table-row-hover-background: var(--hm-orgchart-table-row-hover-background, var(--hm-orgchart-resolved-bg-content));
|
||||
--hm-orgchart-resolved-table-footer-background: var(--hm-orgchart-table-footer-background, var(--hm-orgchart-resolved-bg-subtle));
|
||||
--hm-orgchart-resolved-table-footer-font-weight: var(--hm-orgchart-table-footer-font-weight, var(--hm-orgchart-resolved-font-weight-medium));
|
||||
--hm-orgchart-resolved-table-caption-color: var(--hm-orgchart-table-caption-color, var(--hm-orgchart-resolved-fg-secondary));
|
||||
--hm-orgchart-resolved-table-caption-font-size: var(--hm-orgchart-table-caption-font-size, 11px);
|
||||
--hm-orgchart-resolved-table-caption-line-height: var(--hm-orgchart-table-caption-line-height, 16px);
|
||||
--hm-orgchart-resolved-table-caption-gap: var(--hm-orgchart-table-caption-gap, 8px);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/* OrgChart content cards, not overrides of HM React components.
|
||||
Colors, typography and geometry use standalone OrgChart variables.
|
||||
Observed card geometry is a minimum; text may increase the card height. */
|
||||
.hm-orgchart-employee[data-card-kind] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: var(--hm-orgchart-resolved-card-width-employee);
|
||||
min-width: var(--hm-orgchart-resolved-card-width-employee);
|
||||
min-height: var(--hm-orgchart-resolved-card-height-employee);
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
border: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-current-border, var(--hm-orgchart-resolved-stroke-default));
|
||||
border-radius: var(--hm-orgchart-resolved-radius-container);
|
||||
background: var(--hm-orgchart-current-card-background, var(--hm-orgchart-resolved-bg-surface));
|
||||
color: var(--hm-orgchart-current-card-color, var(--hm-orgchart-resolved-fg-primary));
|
||||
font-family: var(--hm-orgchart-resolved-font-family);
|
||||
text-align: left;
|
||||
}
|
||||
.hm-orgchart-employee[data-card-kind]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 42px;
|
||||
height: 10px;
|
||||
border-radius: 0 var(--hm-orgchart-resolved-radius-container) 0 100%;
|
||||
background: var(--hm-orgchart-resolved-bg-content);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hm-orgchart-employee[data-card-kind="leader"],
|
||||
.hm-orgchart-employee[data-card-kind="topLeader"] {
|
||||
width: var(--hm-orgchart-resolved-card-width-leader);
|
||||
min-width: var(--hm-orgchart-resolved-card-width-leader);
|
||||
min-height: var(--hm-orgchart-resolved-card-height-leader);
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-top: var(--hm-orgchart-resolved-border-top-width) solid var(--hm-orgchart-current-border, var(--hm-orgchart-resolved-action-primary));
|
||||
background: var(--hm-orgchart-current-card-background, var(--hm-orgchart-resolved-bg-content));
|
||||
}
|
||||
.hm-orgchart-employee[data-card-kind="topLeader"] {
|
||||
width: var(--hm-orgchart-resolved-card-width-top-leader);
|
||||
min-width: var(--hm-orgchart-resolved-card-width-top-leader);
|
||||
min-height: var(--hm-orgchart-resolved-card-height-top-leader);
|
||||
border-color: var(--hm-orgchart-accent, var(--hm-orgchart-resolved-action-primary));
|
||||
background: var(--hm-orgchart-current-card-background, var(--hm-orgchart-resolved-bg-surface));
|
||||
}
|
||||
.hm-orgchart-employee[data-card-kind] .hm-orgchart-avatar {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex: 0 0 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: 3.5px;
|
||||
background: var(--hm-orgchart-resolved-bg-subtle);
|
||||
color: var(--hm-orgchart-resolved-fg-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: var(--hm-orgchart-resolved-font-weight-medium);
|
||||
}
|
||||
.hm-orgchart-employee[data-card-kind="leader"] .hm-orgchart-avatar { flex-basis: 36px; width: 36px; height: 36px; }
|
||||
.hm-orgchart-employee[data-card-kind="topLeader"] .hm-orgchart-avatar { flex-basis: 40px; width: 40px; height: 40px; }
|
||||
.hm-orgchart-employee[data-card-kind] .hm-orgchart-avatar img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.hm-orgchart-employee[data-card-kind] .hm-orgchart-avatar img[data-photo-placeholder="true"] {
|
||||
object-fit: contain;
|
||||
background: #f1f5f8;
|
||||
}
|
||||
.hm-orgchart-employee[data-card-kind] .hm-orgchart-employee-details { position: relative; z-index: 1; flex: 1 1 0; min-width: 0; }
|
||||
.hm-orgchart-employee[data-card-kind] .hm-orgchart-person-name {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: var(--hm-orgchart-resolved-font-weight-bold);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
}
|
||||
.hm-orgchart-employee[data-card-kind] .hm-orgchart-person-role {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
line-height: 13px;
|
||||
color: var(--hm-orgchart-resolved-fg-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.hm-orgchart-employee[data-card-kind][data-has-affiliation="true"] .hm-orgchart-person-role { padding-right: 18px; }
|
||||
.hm-orgchart-employee[data-card-kind] .hm-orgchart-person-attributes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 2px 4px;
|
||||
}
|
||||
.hm-orgchart-employee[data-card-kind] .hm-orgchart-job-title {
|
||||
padding: 0 3px;
|
||||
border-radius: var(--hm-orgchart-resolved-radius-control-md);
|
||||
background: var(--hm-orgchart-resolved-accent);
|
||||
color: var(--hm-orgchart-resolved-on-accent);
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
font-weight: var(--hm-orgchart-resolved-font-weight-medium);
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 100%;
|
||||
}
|
||||
.hm-orgchart-employee[data-card-kind] .hm-orgchart-status-label {
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
color: var(--hm-orgchart-resolved-fg-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.hm-orgchart-employee[data-card-kind][data-work-status="dispatched"],
|
||||
.hm-orgchart-employee[data-card-kind][data-work-status="onLeave"] { border-style: dashed; }
|
||||
.hm-orgchart-employee[data-card-kind] .hm-orgchart-affiliation-mark {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 16px;
|
||||
min-height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--hm-orgchart-resolved-stroke-input-default);
|
||||
border-radius: var(--hm-orgchart-resolved-radius-control-md);
|
||||
background: var(--hm-orgchart-resolved-bg-content);
|
||||
color: var(--hm-orgchart-resolved-fg-secondary);
|
||||
font-size: 8px;
|
||||
line-height: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.hm-orgchart-affiliation-mark img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: transparent;
|
||||
}
|
||||
/* Card image boxes are 1.5 times the P3.1 display: 14 -> 21px.
|
||||
Keep the 1px transparent inset; summary logos stay unchanged. */
|
||||
.hm-orgchart-employee[data-card-kind] .hm-orgchart-affiliation-mark {
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
min-height: 23px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 1px;
|
||||
}
|
||||
.hm-orgchart-employee[data-card-kind] .hm-orgchart-affiliation-mark img {
|
||||
inset: 1px;
|
||||
width: calc(100% - 2px);
|
||||
height: calc(100% - 2px);
|
||||
}
|
||||
.hm-orgchart-affiliation-mark[data-logo-available="true"] .hm-orgchart-affiliation-fallback { visibility: hidden; }
|
||||
|
||||
/* Approved SVG cards and compact read-only status cards share content layout.
|
||||
Status cards retain their separate gray surface and do not receive an SVG. */
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) {
|
||||
box-sizing: border-box;
|
||||
width: 108px;
|
||||
min-width: 108px;
|
||||
height: 52px;
|
||||
min-height: 52px;
|
||||
padding: 4px;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="leader"],
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="representative"] {
|
||||
width: 146px;
|
||||
min-width: 146px;
|
||||
height: 68px;
|
||||
min-height: 68px;
|
||||
padding: 6px;
|
||||
gap: 6px;
|
||||
}
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="representative"] { height: 74px; min-height: 74px; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="leader"] { column-gap: 10px; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])::before { content: none; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-card-background {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-avatar { flex-basis: 24px; width: 24px; height: 24px; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="leader"] .hm-orgchart-avatar { flex-basis: 32px; width: 32px; height: 32px; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="representative"] .hm-orgchart-avatar { flex-basis: 36px; width: 36px; height: 36px; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) {
|
||||
--hm-orgchart-person-position-color: #797979;
|
||||
--hm-orgchart-person-position-size: 15px;
|
||||
--hm-orgchart-person-position-line-height: 18px;
|
||||
--hm-orgchart-person-position-letter-spacing: -0.6px;
|
||||
--hm-orgchart-person-name-size: 18px;
|
||||
--hm-orgchart-person-name-line-height: 24px;
|
||||
--hm-orgchart-person-name-letter-spacing: -0.8px;
|
||||
}
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="leader"],
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="representative"] {
|
||||
--hm-orgchart-person-position-size: 20px;
|
||||
--hm-orgchart-person-position-line-height: 22px;
|
||||
--hm-orgchart-person-position-letter-spacing: -0.8px;
|
||||
--hm-orgchart-person-name-size: 24px;
|
||||
--hm-orgchart-person-name-line-height: 30px;
|
||||
--hm-orgchart-person-name-letter-spacing: -0.96px;
|
||||
}
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="representative"] {
|
||||
--hm-orgchart-person-position-color: #224387;
|
||||
--hm-orgchart-person-name-letter-spacing: -0.8px;
|
||||
}
|
||||
.hm-orgchart-employee[data-card-asset][data-card-asset-size="leader"] { --hm-orgchart-person-position-size: 18px; }
|
||||
/* Retain the status border while matching the approved small photo/text origins. */
|
||||
.hm-orgchart-employee[data-card-layout="compact"] { padding: 3px; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-person-text { display: flex; flex-direction: column; gap: 0; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-position-row { display: flex; align-items: center; gap: 4px; margin: 0; padding: 0; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-position-row .hm-orgchart-person-role { flex: 0 0 auto; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-position-row .hm-orgchart-job-title { flex: 0 0 auto; margin: 0; }
|
||||
/* The source's compact tag shares the position row. Align its lower edge with
|
||||
that row so long raw tags clear the unchanged upper-right affiliation logo
|
||||
within the approved 146px cards; row/name gap and typography stay intact. */
|
||||
.hm-orgchart-employee[data-workdesk-presentation="true"][data-card-asset="integrated-system"][data-card-asset-size="leader"] .hm-orgchart-position-row .hm-orgchart-job-title { align-self: flex-end; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-person-role,
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-person-name { margin: 0; padding: 0; white-space: nowrap; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-has-affiliation="true"] .hm-orgchart-person-role { padding: 0; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-person-role {
|
||||
color: var(--hm-orgchart-person-position-color);
|
||||
font-size: var(--hm-orgchart-person-position-size);
|
||||
font-weight: 400;
|
||||
line-height: var(--hm-orgchart-person-position-line-height);
|
||||
letter-spacing: var(--hm-orgchart-person-position-letter-spacing);
|
||||
}
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-person-name {
|
||||
color: #071D3A;
|
||||
font-size: var(--hm-orgchart-person-name-size);
|
||||
font-weight: 600;
|
||||
line-height: var(--hm-orgchart-person-name-line-height);
|
||||
letter-spacing: var(--hm-orgchart-person-name-letter-spacing);
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
}
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-person-name[data-name-overflow="true"] { text-overflow: ellipsis; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]):is([data-card-asset-size="employee"], [data-card-layout="compact"]) .hm-orgchart-person-name { color: #323232; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-person-name:focus-visible { outline: 2px solid var(--hm-orgchart-resolved-stroke-input-focus); outline-offset: 1px; }
|
||||
.hm-orgchart-employee[data-card-asset-size="employee"] .hm-orgchart-affiliation-mark { top: 2px; right: 1px; width: 20px; height: 20px; min-height: 20px; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]):is([data-card-asset-size="employee"], [data-card-layout="compact"]) .hm-orgchart-affiliation-fallback { font-size: 6px; white-space: nowrap; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-person-attributes { position: absolute; z-index: 1; left: 3px; bottom: 3px; width: 28px; justify-content: center; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="leader"] .hm-orgchart-person-attributes,
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="representative"] .hm-orgchart-person-attributes { left: 4px; width: 36px; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-job-title { max-width: 100%; padding: 0 2px; font-size: 8px; line-height: 12px; letter-spacing: -0.2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="leader"] .hm-orgchart-job-title { font-size: 9px; font-weight: 700; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]):is([data-card-asset-size="employee"], [data-card-layout="compact"]) .hm-orgchart-job-title { font-size: 8.5px; font-weight: 600; }
|
||||
/* Workdesk source: index.css .rank-position/.role-rank and .bg-*.
|
||||
Preserve the approved SVG geometry and position/name typography. The photo
|
||||
slot uses the existing reserved width because the approved cards are smaller
|
||||
than Workdesk's source cards; missing photos use the shared neutral icon. */
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]),
|
||||
.hm-orgchart-employee[data-workdesk-presentation="true"] {
|
||||
--hm-orgchart-role-tag-color: #0e5492;
|
||||
--hm-orgchart-role-tag-background: rgba(14, 84, 146, 0.08);
|
||||
--hm-orgchart-role-tag-border: rgba(14, 84, 146, 0.2);
|
||||
}
|
||||
.hm-orgchart-employee[data-card-asset="business-planning"] {
|
||||
--hm-orgchart-role-tag-color: #254e7e;
|
||||
--hm-orgchart-role-tag-background: rgba(37, 78, 126, 0.1);
|
||||
--hm-orgchart-role-tag-border: rgba(37, 78, 126, 0.26);
|
||||
}
|
||||
.hm-orgchart-employee[data-card-asset="talent-growth"] {
|
||||
--hm-orgchart-role-tag-color: #0e67c8;
|
||||
--hm-orgchart-role-tag-background: rgba(14, 103, 200, 0.1);
|
||||
--hm-orgchart-role-tag-border: rgba(14, 103, 200, 0.26);
|
||||
}
|
||||
.hm-orgchart-employee[data-card-asset="integrated-system"] {
|
||||
--hm-orgchart-role-tag-color: #6e47a0;
|
||||
--hm-orgchart-role-tag-background: rgba(110, 71, 160, 0.1);
|
||||
--hm-orgchart-role-tag-border: rgba(110, 71, 160, 0.26);
|
||||
}
|
||||
.hm-orgchart-employee[data-card-asset="design-planning"] {
|
||||
--hm-orgchart-role-tag-color: #069a95;
|
||||
--hm-orgchart-role-tag-background: rgba(6, 154, 149, 0.1);
|
||||
--hm-orgchart-role-tag-border: rgba(6, 154, 149, 0.26);
|
||||
}
|
||||
.hm-orgchart-employee[data-card-asset="solution-sales"] {
|
||||
--hm-orgchart-role-tag-color: #518b35;
|
||||
--hm-orgchart-role-tag-background: rgba(81, 139, 53, 0.1);
|
||||
--hm-orgchart-role-tag-border: rgba(81, 139, 53, 0.26);
|
||||
}
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-avatar,
|
||||
.hm-orgchart-employee[data-workdesk-presentation="true"] .hm-orgchart-avatar {
|
||||
border-radius: 3.5px;
|
||||
background: transparent;
|
||||
}
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-avatar { height: 44px; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="leader"] .hm-orgchart-avatar { height: 56px; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="representative"] .hm-orgchart-avatar { height: 62px; }
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-job-title,
|
||||
.hm-orgchart-employee[data-workdesk-presentation="true"] .hm-orgchart-job-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 13px;
|
||||
padding: 0 3px;
|
||||
border: 1px solid var(--hm-orgchart-role-tag-border);
|
||||
border-radius: 4px;
|
||||
background: var(--hm-orgchart-role-tag-background);
|
||||
color: var(--hm-orgchart-role-tag-color);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]):is([data-card-asset-size="employee"], [data-card-layout="compact"]) .hm-orgchart-job-title {
|
||||
padding: 0 2px;
|
||||
font-size: 8.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Status is retained for assistive reading; the specified two-line card
|
||||
composition has no third status row. Supplementary status cards are intact. */
|
||||
.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-status-label { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
.hm-orgchart-person-name-tooltip { position: fixed; z-index: 10; box-sizing: border-box; max-width: min(320px, calc(100vw - 16px)); padding: 6px 8px; border: 1px solid #224387; border-radius: 4px; background: #ffffff; color: #071D3A; font-family: var(--hm-orgchart-resolved-font-family); font-size: 14px; line-height: 20px; overflow-wrap: anywhere; pointer-events: auto; }
|
||||
.hm-orgchart-person-name-tooltip[hidden] { display: none; }
|
||||
@@ -0,0 +1,48 @@
|
||||
.hm-orgchart-organization-link {
|
||||
display: block;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.hm-orgchart-organization-link:hover { text-decoration: underline; }
|
||||
.hm-orgchart-organization-link:focus-visible,
|
||||
.hm-orgchart-employee[role="button"]:focus-visible,
|
||||
.hm-orgchart-employee-dialog-close:focus-visible {
|
||||
outline: 2px solid var(--hm-orgchart-resolved-stroke-input-focus);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.hm-orgchart-employee[role="button"] { cursor: pointer; }
|
||||
.hm-orgchart-employee-dialog {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: calc(100dvh - 32px);
|
||||
padding: var(--hm-orgchart-resolved-spacing-xxl);
|
||||
border: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-resolved-stroke-default);
|
||||
border-radius: var(--hm-orgchart-resolved-radius-container);
|
||||
background: var(--hm-orgchart-resolved-bg-surface);
|
||||
color: var(--hm-orgchart-resolved-fg-primary);
|
||||
font: inherit;
|
||||
overflow: auto;
|
||||
}
|
||||
.hm-orgchart-employee-dialog::backdrop {
|
||||
background: color-mix(in srgb, var(--hm-orgchart-resolved-fg-primary) 38%, transparent);
|
||||
}
|
||||
.hm-orgchart-employee-dialog-header { display: flex; align-items: center; justify-content: space-between; gap: var(--hm-orgchart-resolved-spacing-lg); }
|
||||
.hm-orgchart-employee-dialog-header h2 { margin: 0; font-size: 18px; font-weight: var(--hm-orgchart-resolved-font-weight-bold); }
|
||||
.hm-orgchart-employee-dialog-close {
|
||||
padding: var(--hm-orgchart-resolved-spacing-xs) var(--hm-orgchart-resolved-spacing-sm);
|
||||
border: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-resolved-stroke-default);
|
||||
border-radius: var(--hm-orgchart-resolved-radius-control-md);
|
||||
background: var(--hm-orgchart-resolved-bg-content);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hm-orgchart-employee-dialog-fields {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: var(--hm-orgchart-resolved-spacing-md) var(--hm-orgchart-resolved-spacing-lg);
|
||||
margin: var(--hm-orgchart-resolved-spacing-xl) 0 0;
|
||||
}
|
||||
.hm-orgchart-employee-dialog-fields dt { color: var(--hm-orgchart-resolved-fg-secondary); }
|
||||
.hm-orgchart-employee-dialog-fields dd { margin: 0; overflow-wrap: anywhere; }
|
||||
@@ -0,0 +1,147 @@
|
||||
/* Content composition only; no Header/Navigation or HM component overrides. */
|
||||
/* Full Composition: Scale wrapper (.hm-orgchart-composition) > Base Stage (.hm-orgchart-stage).
|
||||
The stage keeps its natural width (tree + side) and is uniformly scaled by Core JS;
|
||||
there is no reflow breakpoint and no inner horizontal scroll. Column/group gaps are
|
||||
screen-only layout values (no data or parentId change). */
|
||||
.hm-orgchart-composition { position: relative; width: 100%; min-width: 0; overflow: hidden; }
|
||||
.hm-orgchart-stage {
|
||||
/* Engine padding (32px) + node margins (16px) already separate the six 252px
|
||||
organization columns; the tree wrapper adds no extra width. */
|
||||
--hm-orgchart-column-gap: 0px;
|
||||
display: grid;
|
||||
grid-template-columns: max-content 330px;
|
||||
column-gap: var(--hm-orgchart-resolved-spacing-xl);
|
||||
align-items: start;
|
||||
width: max-content;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
.hm-orgchart-composition-no-side .hm-orgchart-stage { grid-template-columns: max-content; }
|
||||
/* Stage-level rows: intro/hub are centered on the whole stage (tree + side). */
|
||||
.hm-orgchart-main { display: contents; }
|
||||
.hm-orgchart-slot-side, .hm-orgchart-slot-footer, .hm-orgchart-slot-belowTree { min-width: 0; }
|
||||
.hm-orgchart-slot-side { grid-column: 2; grid-row: 1 / span 4; }
|
||||
.hm-orgchart-slot-footer { grid-column: 1 / -1; grid-row: 5; margin-top: 16px; contain: inline-size; }
|
||||
.hm-orgchart-slot-side, .hm-orgchart-slot-footer, .hm-orgchart-slot-belowTree { display: grid; gap: 16px; }
|
||||
.hm-orgchart-slot-belowTree { grid-column: 1; grid-row: 4; margin-top: 16px; contain: inline-size; }
|
||||
.hm-orgchart-intro { grid-column: 1 / -1; grid-row: 1; contain: inline-size; display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: start; gap: 16px; }
|
||||
.hm-orgchart-intro .hm-orgchart-heading { grid-column: 1; }
|
||||
.hm-orgchart-top-leaders { grid-column: 2; display: flex; justify-content: center; gap: 12px; }
|
||||
.hm-orgchart-hub {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: 28px auto 18px;
|
||||
z-index: 1;
|
||||
padding: 8px 20px;
|
||||
border-radius: var(--hm-orgchart-resolved-radius-pill);
|
||||
background: var(--hm-orgchart-resolved-bg-brand);
|
||||
color: var(--hm-orgchart-resolved-on-action-primary);
|
||||
font-weight: var(--hm-orgchart-resolved-font-weight-bold);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.hm-orgchart-hub::before, .hm-orgchart-hub::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
height: 18px;
|
||||
border-left: 1px dashed var(--hm-orgchart-resolved-stroke-default);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hm-orgchart-hub::before { bottom: 100%; }
|
||||
.hm-orgchart-hub::after { top: 100%; }
|
||||
/* Stage 4: hub-to-organization branch lines (visual only, not data relations).
|
||||
Each top-level tree draws its own bar segment so the six branches read as one row. */
|
||||
.hm-orgchart-main > .hm-orgchart-viewport { grid-column: 1; grid-row: 3; overflow: visible; padding-top: 0; border-top-color: transparent; border-radius: 0 0 var(--hm-orgchart-resolved-radius-container) var(--hm-orgchart-resolved-radius-container); }
|
||||
.hm-orgchart-main > .hm-orgchart-viewport .hm-orgchart-forest { align-items: flex-start; justify-content: flex-start; }
|
||||
.hm-orgchart-main > .hm-orgchart-viewport .hm-orgchart-tree { position: relative; padding: 18px calc(var(--hm-orgchart-column-gap) / 2) 0; }
|
||||
.hm-orgchart-main > .hm-orgchart-viewport .hm-orgchart-tree::before,
|
||||
.hm-orgchart-main > .hm-orgchart-viewport .hm-orgchart-tree::after { content: ''; position: absolute; top: 0; pointer-events: none; border-top: 1px dashed var(--hm-orgchart-resolved-stroke-default); }
|
||||
.hm-orgchart-main > .hm-orgchart-viewport .hm-orgchart-tree::before { left: 0; right: 0; }
|
||||
.hm-orgchart-main > .hm-orgchart-viewport .hm-orgchart-tree:first-child::before { left: 50%; }
|
||||
.hm-orgchart-main > .hm-orgchart-viewport .hm-orgchart-tree:last-child::before { right: 50%; }
|
||||
.hm-orgchart-main > .hm-orgchart-viewport .hm-orgchart-tree:only-child::before { display: none; }
|
||||
.hm-orgchart-main > .hm-orgchart-viewport .hm-orgchart-tree::after { left: 50%; height: 18px; border-top: 0; border-left: 1px dashed var(--hm-orgchart-resolved-stroke-default); }
|
||||
/* Six top branches are siblings supplied by the data. Deeper cells/teams stack
|
||||
inside their branch, with engine hierarchy and collapse behavior intact. */
|
||||
.hm-orgchart-engine.orgchart .node[data-depth] { width: 204px; }
|
||||
.hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-organization {
|
||||
width: 204px;
|
||||
padding: 10px;
|
||||
background: var(--hm-orgchart-current-card-background);
|
||||
border-top: var(--hm-orgchart-resolved-border-top-width) solid var(--hm-orgchart-current-border);
|
||||
}
|
||||
.hm-orgchart-engine.orgchart .node[data-depth="0"] { width: 244px; }
|
||||
.hm-orgchart-engine.orgchart .node[data-depth="0"] .hm-orgchart-organization { width: 244px; }
|
||||
.hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth="1"]) > .nodes,
|
||||
.hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth="2"]) > .nodes,
|
||||
.hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth="3"]) > .nodes { flex-direction: column; align-items: center; }
|
||||
/* Stacked siblings: the engine's horizontal sibling bar becomes a vertical
|
||||
spine through the 20px engine node margin above each cell/team. */
|
||||
.hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth="1"]) > .nodes > .hierarchy::before,
|
||||
.hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth="2"]) > .nodes > .hierarchy::before,
|
||||
.hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth="3"]) > .nodes > .hierarchy::before { top: -20px; left: calc(50% - 1px); width: 2px; height: 20px; border-width: 0 0 0 2px; }
|
||||
/* Stage 3 headers. Colors come from the per-organization preset variables;
|
||||
sizes are DESIGN_ADAPTATION (Workdesk computed size/padding NOT VERIFIED). */
|
||||
.hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-organization-name {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 8px 0 0;
|
||||
text-align: center;
|
||||
border-radius: var(--hm-orgchart-resolved-radius-container);
|
||||
color: var(--hm-orgchart-current-department-color);
|
||||
}
|
||||
.hm-orgchart-engine.orgchart .node[data-header-role="organization"] .hm-orgchart-organization-name {
|
||||
min-height: var(--hm-orgchart-title-height, 40px);
|
||||
padding: 6px 12px;
|
||||
background: var(--hm-orgchart-group-background);
|
||||
font-size: var(--hm-orgchart-title-font-size, 18px);
|
||||
font-weight: var(--hm-orgchart-resolved-font-weight-bold);
|
||||
line-height: var(--hm-orgchart-title-line-height, 26px);
|
||||
}
|
||||
.hm-orgchart-engine.orgchart .node[data-header-role="group"] .hm-orgchart-organization-name {
|
||||
min-height: var(--hm-orgchart-group-header-height, 32px);
|
||||
padding: 4px 8px;
|
||||
background: var(--hm-orgchart-current-border);
|
||||
font-size: var(--hm-orgchart-group-header-font-size, 14px);
|
||||
font-weight: var(--hm-orgchart-resolved-font-weight-bold);
|
||||
line-height: var(--hm-orgchart-group-header-line-height, 20px);
|
||||
}
|
||||
.hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-leader { margin: 10px auto; }
|
||||
.hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employees { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 10px; }
|
||||
.hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee { flex-shrink: 0; }
|
||||
.hm-orgchart-preview-grid { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 12px; }
|
||||
.hm-orgchart-card-specimen { display: flex; flex-direction: column; align-items: center; gap: 4px; max-width: 100%; }
|
||||
.hm-orgchart-card-caption { margin: 0; max-width: 180px; font-size: 11px; line-height: 16px; text-align: center; }
|
||||
.hm-orgchart-component-cards { min-width: 0; }
|
||||
.hm-orgchart-logo-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--hm-orgchart-resolved-spacing-lg); padding: 0; list-style: none; }
|
||||
.hm-orgchart-logo-list li { display: flex; flex-direction: column; align-items: center; gap: var(--hm-orgchart-resolved-spacing-sm); padding: var(--hm-orgchart-resolved-spacing-lg); border: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-resolved-stroke-default); border-radius: var(--hm-orgchart-resolved-radius-container); }
|
||||
.hm-orgchart-logo-list .hm-orgchart-affiliation-mark { position: relative; width: 96px; height: 96px; }
|
||||
.hm-orgchart-department-colors { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: var(--hm-orgchart-resolved-spacing-lg); }
|
||||
.hm-orgchart-department-colors > p { grid-column: 1 / -1; }
|
||||
.hm-orgchart-representative-preview { grid-column: 1 / -1; }
|
||||
.hm-orgchart-color-preview { min-width: 0; padding: var(--hm-orgchart-resolved-spacing-lg); border: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-current-border); border-radius: var(--hm-orgchart-resolved-radius-container); background: var(--hm-orgchart-current-card-background); overflow-wrap: anywhere; }
|
||||
.hm-orgchart-color-department-name { display: flex; align-items: center; justify-content: center; min-height: var(--hm-orgchart-title-height, 40px); padding: 6px 12px; font-size: var(--hm-orgchart-title-font-size, 18px); line-height: var(--hm-orgchart-title-line-height, 26px); font-weight: var(--hm-orgchart-resolved-font-weight-bold); background: var(--hm-orgchart-current-gradient); color: var(--hm-orgchart-current-department-color); border-radius: var(--hm-orgchart-resolved-radius-container); }
|
||||
.hm-orgchart-color-legend { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: var(--hm-orgchart-resolved-spacing-sm); }
|
||||
.hm-orgchart-color-legend dd { margin: 0; display: grid; gap: 4px; }
|
||||
.hm-orgchart-color-swatch { display: block; width: 44px; height: 22px; border: 1px solid var(--hm-orgchart-resolved-stroke-default); }
|
||||
.hm-orgchart-color-swatch[data-css-property="color"] { text-align: center; font-weight: var(--hm-orgchart-resolved-font-weight-bold); }
|
||||
.hm-orgchart-contrast-normal, .hm-orgchart-contrast-candidate, .hm-orgchart-contrast-chip {
|
||||
margin: var(--hm-orgchart-resolved-spacing-sm) 0;
|
||||
padding: 4px 8px;
|
||||
font-size: 15px;
|
||||
font-weight: var(--hm-orgchart-resolved-font-weight-bold);
|
||||
line-height: 22px;
|
||||
border-radius: var(--hm-orgchart-resolved-radius-container);
|
||||
background: var(--hm-orgchart-current-gradient);
|
||||
color: var(--hm-orgchart-current-department-color);
|
||||
}
|
||||
.hm-orgchart-contrast-candidate { color: var(--hm-orgchart-contrast-candidate-color, #10243b); }
|
||||
.hm-orgchart-contrast-chip-label { display: inline-block; background: var(--hm-orgchart-contrast-candidate-chip-background, #ffffff); color: var(--hm-orgchart-contrast-candidate-color, #10243b); padding: 0 4px; border-radius: var(--hm-orgchart-resolved-radius-control-md); }
|
||||
.hm-orgchart-contrast-comparison { min-width: 0; }
|
||||
.hm-orgchart-contrast-results { margin: var(--hm-orgchart-resolved-spacing-sm) 0; }
|
||||
.hm-orgchart-contrast-results dd { margin-left: 0; }
|
||||
.hm-orgchart-contrast-result, .hm-orgchart-contrast-stops { margin: var(--hm-orgchart-resolved-spacing-xs) 0; }
|
||||
@@ -0,0 +1,119 @@
|
||||
/* P3 read-only summary presenters. Values are from Workdesk index.css:
|
||||
headcount-panel / bold-text / sidebar- / #summary-table and the bottom
|
||||
.section legend. These are standalone OrgChart content, not HM overrides. */
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-organization-totals {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
width: calc(var(--hm-orgchart-workdesk-summary-width) + 2px);
|
||||
overflow: visible;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
color: #18253a;
|
||||
font-size: 14px;
|
||||
}
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-organization-totals-title {
|
||||
width: 176px;
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
border-radius: 7px 0 0 0;
|
||||
background: linear-gradient(180deg, #004890, #00366f);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 15.2px;
|
||||
text-align: center;
|
||||
}
|
||||
.hm-orgchart-workdesk-summary-frame {
|
||||
width: 100%;
|
||||
box-shadow: 0 6.5px 12.9px rgba(51, 73, 96, 0.11);
|
||||
border: 0.8px solid #c4d3e2;
|
||||
border-radius: 6.33px 0 6.33px 6.33px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hm-orgchart-workdesk-position-table {
|
||||
width: var(--hm-orgchart-workdesk-summary-width);
|
||||
table-layout: fixed;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
color: #18253a;
|
||||
font-size: 14px;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
}
|
||||
/* Preserve the 330px fixture table while giving its complete organization
|
||||
labels and three-character first position enough room at the source's 14px.
|
||||
Remaining numeric columns share the available width without changing data. */
|
||||
.hm-orgchart-workdesk-label-column { width: 112px; }
|
||||
.hm-orgchart-workdesk-value-column { width: auto; }
|
||||
.hm-orgchart-workdesk-value-column:nth-child(2) { width: 46px; }
|
||||
.hm-orgchart-workdesk-position-table th,
|
||||
.hm-orgchart-workdesk-position-table td {
|
||||
height: 19px;
|
||||
padding: 0;
|
||||
border-top: 0.5px solid #d7e1ea;
|
||||
font-weight: 400;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
.hm-orgchart-workdesk-position-table th:first-child { padding: 0 7px; }
|
||||
.hm-orgchart-workdesk-position-table :is(th, td) + :is(th, td) { border-left: 0.8px solid #d7e1ea; }
|
||||
.hm-orgchart-workdesk-position-table thead { color: #223149; }
|
||||
.hm-orgchart-workdesk-position-table thead th,
|
||||
.hm-orgchart-workdesk-position-table tfoot :is(th, td) { background: #f2f6fa; }
|
||||
.hm-orgchart-workdesk-position-table tfoot :is(th, td) { height: 20px; }
|
||||
.hm-orgchart-workdesk-count-cell { font-variant-numeric: tabular-nums; }
|
||||
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 55.4px;
|
||||
min-height: 55.4px;
|
||||
padding: 0 19px;
|
||||
border: 0;
|
||||
border-radius: 10.7px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
box-shadow: 0 5.4px 14.3px rgba(44, 68, 96, 0.08);
|
||||
color: #1c2534;
|
||||
}
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar-list {
|
||||
flex-wrap: nowrap;
|
||||
gap: 26.8px;
|
||||
}
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar-item {
|
||||
position: relative;
|
||||
height: 42.9px;
|
||||
min-height: 42.9px;
|
||||
padding: 0;
|
||||
gap: 4px;
|
||||
font-size: 19.6px;
|
||||
font-weight: 500;
|
||||
line-height: 19.7px;
|
||||
}
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar-item + .hm-orgchart-affiliation-bar-item { border: 0; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar-item + .hm-orgchart-affiliation-bar-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -13.9px;
|
||||
width: 0.9px;
|
||||
height: 21.4px;
|
||||
background: rgba(113, 142, 176, 0.48);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar-item dt { gap: 0.9px; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar .hm-orgchart-affiliation-mark {
|
||||
width: 39.3px;
|
||||
height: 39.3px;
|
||||
flex-basis: 39.3px;
|
||||
}
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar .hm-orgchart-affiliation-mark img { object-fit: cover; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar-total {
|
||||
color: #071d3a;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/* Read-only composition (displayMode: 'readOnly'): Workdesk-style view-only canvas.
|
||||
Scoped to the composition attribute; interactive composition, standalone
|
||||
components and the HM Theme are unaffected. Canvas geometry follows the
|
||||
measured Workdesk layout (1920 × 914.5, axis X 935); spacing that differs
|
||||
from Workdesk is a DESIGN_ADAPTATION to the confirmed card sizes. */
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] {
|
||||
--hm-orgchart-group-title-font-size: 20px;
|
||||
--hm-orgchart-group-title-font-weight: 700;
|
||||
--hm-orgchart-group-title-line-height: 19.7px;
|
||||
background: #F5F8FB;
|
||||
}
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: grid;
|
||||
/* Heading | zero-width axis column at X 935 | side. */
|
||||
grid-template-columns: 935px 0 minmax(0, 1fr);
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
grid-template-areas:
|
||||
"heading axis side"
|
||||
"tree tree tree"
|
||||
"status status status"
|
||||
"footer footer footer";
|
||||
column-gap: 0;
|
||||
width: max-content;
|
||||
min-width: 1920px;
|
||||
min-height: 914.5px;
|
||||
padding: 12px 0;
|
||||
background: #F5F8FB;
|
||||
}
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-heading { grid-area: heading; justify-self: start; align-self: start; max-width: calc(100% - 52px - 96px); margin: 0 0 0 52px; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-title { font-size: 32px; line-height: 40px; letter-spacing: -0.5px; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-date { margin: 4px 0 0; font-size: 14px; line-height: 20px; }
|
||||
|
||||
/* Top leader and hub on the canvas axis, joined by one dashed line. */
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-axis {
|
||||
grid-area: axis;
|
||||
justify-self: center;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
width: max-content;
|
||||
}
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-axis::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
border-left: 1px dashed var(--hm-orgchart-resolved-stroke-default);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-top-leaders { position: relative; z-index: 1; grid-column: auto; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-hub { grid-area: auto; margin: auto 0 18px; white-space: nowrap; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-hub::before { content: none; }
|
||||
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-slot-side { grid-area: side; justify-self: end; align-self: start; margin-right: 20px; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-slot-belowTree { grid-area: status; align-self: end; margin: 12px 0 0 52px; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-slot-footer { grid-area: footer; margin: 8px 57px 0; }
|
||||
|
||||
/* Hierarchy: no scroll frame; columns are positioned by measured header centers. */
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-main > .hm-orgchart-viewport { grid-area: tree; padding: 0; border: 0; border-radius: 0; overflow: visible; }
|
||||
/* Positioned columns are visually translated; keep their layout overhang out of the canvas width. */
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-viewport:has(> .hm-orgchart-forest[data-layout="workdesk"]) { contain: inline-size; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-forest[data-layout="workdesk"] { display: grid; position: relative; width: 100%; min-width: 0; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-forest[data-layout="workdesk"] > .hm-orgchart-tree {
|
||||
grid-area: 1 / 1;
|
||||
justify-self: start;
|
||||
width: max-content;
|
||||
margin-left: var(--hm-orgchart-column-center);
|
||||
translate: -50% 0;
|
||||
}
|
||||
/* One hub branch bar from the first to the last column center. */
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-forest[data-layout="workdesk"]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: var(--hm-orgchart-branch-start);
|
||||
width: calc(var(--hm-orgchart-branch-end) - var(--hm-orgchart-branch-start));
|
||||
border-top: 1px dashed var(--hm-orgchart-resolved-stroke-default);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-forest[data-layout="workdesk"] > .hm-orgchart-tree::before { content: none; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart { min-width: 0; padding: 0; }
|
||||
|
||||
/* Organization nodes: header + people only, no group card frame. */
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-depth] { width: max-content; min-width: 108px; margin: 0 8px 16px; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-header-role="organization"] {
|
||||
--hm-orgchart-title-height: 36px;
|
||||
}
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-organization {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-organization-name { align-self: stretch; margin: 0; }
|
||||
/* Workdesk 1Depth typography; vertical padding must not enlarge the 36px header. */
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-header-role="organization"] .hm-orgchart-organization-name {
|
||||
min-width: 146px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 36px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-header-role="group"] {
|
||||
--hm-orgchart-group-header-height: 30.4px;
|
||||
}
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-header-role="group"] .hm-orgchart-organization-name {
|
||||
align-self: center;
|
||||
width: 114px;
|
||||
min-width: 114px;
|
||||
min-height: 30.4px;
|
||||
padding: 0;
|
||||
background: var(--hm-orgchart-current-gradient);
|
||||
border-radius: 7.1px;
|
||||
box-shadow: 0 3.6px 8px rgba(45, 73, 104, 0.2);
|
||||
font-size: var(--hm-orgchart-group-title-font-size);
|
||||
font-weight: var(--hm-orgchart-group-title-font-weight);
|
||||
line-height: var(--hm-orgchart-group-title-line-height);
|
||||
letter-spacing: normal;
|
||||
text-align: center;
|
||||
}
|
||||
/* Workdesk .hw/.hr labels share compact typography; .sw/.ux retain 20px/700. */
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-header-role="group"][data-organization-id^="business-planning-"] .hm-orgchart-organization-name,
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-header-role="group"][data-organization-id^="talent-growth-"] .hm-orgchart-organization-name { font-size: 18px; font-weight: 600; line-height: 16.9px; }
|
||||
/* Stacked cells: the vertical spine follows the 16px node gap. */
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth]) > .nodes > .hierarchy::before { top: -16px; height: 16px; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-leader { margin: 8px 0 0; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employees { flex-direction: column; flex-wrap: nowrap; align-items: center; gap: 8px; margin-top: 8px; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employees:empty { display: none; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee { margin-top: 0; }
|
||||
/* Final external gap: this must follow the generic employee margin reset.
|
||||
Keeping semantic leader class preserves badges while SVG size uses ancestry. */
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee.hm-orgchart-leader { margin: 8px 0 0; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-header-role="group"] .hm-orgchart-employee.hm-orgchart-leader { margin-top: 8px; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-header-role="group"] .hm-orgchart-employees:first-of-type { margin-top: 8px; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-employee-details { gap: 0; }
|
||||
|
||||
/* Cells/teams of Div and Design: one horizontal row with the engine's sibling bar. */
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-children-layout="horizontal"]) > .nodes { flex-direction: row; align-items: flex-start; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-children-layout="horizontal"]) > .nodes > .hierarchy::before {
|
||||
top: -9px; left: 0; width: 100%; height: auto; border-width: 2px 0 0 0;
|
||||
}
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-children-layout="horizontal"]) > .nodes > .hierarchy:first-child::before { left: calc(50% - 1px); width: calc(50% + 1px); }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-children-layout="horizontal"]) > .nodes > .hierarchy:last-child::before { left: 0; width: calc(50% + 1px); }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-children-layout="horizontal"]) > .nodes > .hierarchy:only-child::before { left: calc(50% - 1px); width: 2px; }
|
||||
|
||||
/* Workdesk compact artwork. Preserve flow placement for unknown organizations;
|
||||
the measured preset alone enables this original 1920×914.5 coordinate layer. */
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] { grid-template-rows: 224.2px auto 1fr auto; height: 914.5px; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-axis { width: 198px; height: 224.2px; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-top-leaders { position: absolute; top: 35.3px; z-index: 35; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-hub {
|
||||
position: absolute;
|
||||
top: 176px;
|
||||
left: 50%;
|
||||
translate: -50% 0;
|
||||
z-index: 12;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: max-content;
|
||||
/* The fixed 198px axis is a center anchor, not a text-width constraint. */
|
||||
max-width: none;
|
||||
min-width: 198px;
|
||||
height: 42px;
|
||||
margin: 0;
|
||||
padding: 9px 24px 9px 22px;
|
||||
border-radius: 892.1px;
|
||||
background: linear-gradient(-28.46deg, #809dd7, #45639f 79.33%);
|
||||
gap: 8px;
|
||||
}
|
||||
.hm-orgchart-hub-icon { width: 20px; height: 20px; flex: 0 0 20px; stroke: #f8fcff; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.38)); }
|
||||
.hm-orgchart-hub-label { position: relative; font-size: 21px; font-weight: 750; line-height: 24px; letter-spacing: normal; background: linear-gradient(180deg, #dbeeff, #f8fcff 15.38%, #fff 48.08%, #f4faff 79.33%, #dbeeff); background-clip: text; -webkit-text-fill-color: transparent; filter: drop-shadow(0 0 10px rgba(0, 0, 0, 0.5)); }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-axis::before,
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-hub::after,
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-forest::before,
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-tree::after { content: none; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-viewport { position: relative; z-index: 7; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-heading,
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-footer { position: relative; z-index: 7; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-side { position: relative; z-index: 18; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-belowTree { position: relative; z-index: 32; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-belowTree { position: absolute; left: 32px; bottom: 70px; width: 240px; margin: 0; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-status-summary { gap: 12px; flex-wrap: nowrap; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-footer { position: absolute; left: 57px; right: 57px; bottom: 12px; margin: 0; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-person-role,
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-person-name,
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-job-title { white-space: nowrap; }
|
||||
/* Keep the logo above the unchanged position row. */
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-employee[data-card-asset-size="leader"] .hm-orgchart-affiliation-mark { top: 0; }
|
||||
.hm-orgchart-workdesk-overlay { position: absolute; inset: 0; width: 1920px; height: 914.5px; z-index: 4; pointer-events: none; }
|
||||
.hm-orgchart-workdesk-decoration { position: absolute; z-index: 0; pointer-events: none; user-select: none; object-fit: contain; }
|
||||
.hm-orgchart-workdesk-ring { top: 0; left: 50%; width: 1757px; height: 901px; translate: -50% 0; mix-blend-mode: multiply; }
|
||||
.hm-orgchart-workdesk-sky { inset: 0; width: 100%; height: 100%; object-position: top center; opacity: 0.1; }
|
||||
.hm-orgchart-workdesk-meeting { left: 68px; bottom: 32px; width: 557px; height: 344px; opacity: 0.8; mix-blend-mode: multiply; }
|
||||
.hm-orgchart-workdesk-grid { left: 62px; bottom: 18.9px; width: 1768px; height: 275px; }
|
||||
.hm-orgchart-workdesk-hub-frame { inset: 0; width: 100%; height: 100%; z-index: -1; object-position: top; }
|
||||
/* P3: artwork owns representative/hub/top-level routes. Hide every engine
|
||||
hierarchy route in readOnly only, including upstream pseudo-element paths. */
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .hierarchy::before,
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .hierarchy::after,
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node::before,
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node::after { content: none; display: none; }
|
||||
|
||||
/* Organization totals (compact presenter of the shared Summary). */
|
||||
.hm-orgchart-organization-totals {
|
||||
width: 332px;
|
||||
overflow: hidden;
|
||||
border: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-resolved-table-divider-color);
|
||||
border-radius: var(--hm-orgchart-resolved-radius-container);
|
||||
background: var(--hm-orgchart-resolved-table-background);
|
||||
color: var(--hm-orgchart-resolved-table-text);
|
||||
}
|
||||
.hm-orgchart-organization-totals-title {
|
||||
margin: 0;
|
||||
padding: 3px 8px;
|
||||
background: var(--hm-orgchart-resolved-table-header-text);
|
||||
color: var(--hm-orgchart-resolved-on-action-primary);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: var(--hm-orgchart-resolved-font-weight-bold);
|
||||
text-align: center;
|
||||
}
|
||||
.hm-orgchart-organization-totals-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; }
|
||||
.hm-orgchart-organization-total {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 3px 10px;
|
||||
border-top: var(--hm-orgchart-resolved-table-divider-width) solid var(--hm-orgchart-resolved-table-divider-color);
|
||||
font-size: var(--hm-orgchart-resolved-table-font-size);
|
||||
line-height: var(--hm-orgchart-resolved-table-line-height);
|
||||
}
|
||||
.hm-orgchart-organization-total:nth-child(odd) { border-right: var(--hm-orgchart-resolved-table-divider-width) solid var(--hm-orgchart-resolved-table-divider-color); }
|
||||
.hm-orgchart-organization-total:nth-child(odd):last-child { grid-column: 1 / -1; border-right: 0; }
|
||||
.hm-orgchart-organization-total dt { min-width: 0; overflow-wrap: anywhere; }
|
||||
.hm-orgchart-organization-total dd { flex: 0 0 auto; margin: 0; font-variant-numeric: tabular-nums; font-weight: var(--hm-orgchart-resolved-font-weight-medium); }
|
||||
.hm-orgchart-organization-total-sum { background: var(--hm-orgchart-resolved-table-footer-background); font-weight: var(--hm-orgchart-resolved-font-weight-bold); }
|
||||
.hm-orgchart-organization-total-sum dd { font-weight: var(--hm-orgchart-resolved-font-weight-bold); }
|
||||
|
||||
/* Status groups: header pill + cards, no surrounding card. Title/card colors
|
||||
come from the confirmed status styles in summary-sections.css. */
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-status-summary { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px 48px; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-status-group { display: grid; justify-items: start; gap: 8px; padding: 0; border: 0; border-radius: 0; background: none; box-shadow: none; }
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-status-group > .hm-orgchart-summary-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 108px;
|
||||
min-height: 24px;
|
||||
margin: 0;
|
||||
border-radius: var(--hm-orgchart-resolved-radius-container);
|
||||
color: var(--hm-orgchart-resolved-on-action-primary);
|
||||
font-size: var(--hm-orgchart-group-title-font-size);
|
||||
font-weight: var(--hm-orgchart-group-title-font-weight);
|
||||
line-height: var(--hm-orgchart-group-title-line-height);
|
||||
}
|
||||
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-status-employees { gap: 12px; margin: 0; }
|
||||
|
||||
/* Affiliation bar: one untitled line of logo · name · count and the total. */
|
||||
.hm-orgchart-affiliation-bar {
|
||||
padding: 8px 24px;
|
||||
border: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-resolved-stroke-input-default);
|
||||
border-radius: var(--hm-orgchart-resolved-radius-container);
|
||||
background: var(--hm-orgchart-resolved-bg-surface);
|
||||
}
|
||||
.hm-orgchart-affiliation-bar-list { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; row-gap: 4px; margin: 0; }
|
||||
.hm-orgchart-affiliation-bar-item { display: flex; align-items: center; gap: 6px; padding: 0 20px; font-size: 16px; line-height: 24px; white-space: nowrap; }
|
||||
.hm-orgchart-affiliation-bar-item + .hm-orgchart-affiliation-bar-item { border-left: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-resolved-stroke-default); }
|
||||
.hm-orgchart-affiliation-bar-item dt { display: flex; align-items: center; gap: 6px; }
|
||||
.hm-orgchart-affiliation-bar-item dd { margin: 0; font-variant-numeric: tabular-nums; }
|
||||
.hm-orgchart-affiliation-bar-total { font-weight: var(--hm-orgchart-resolved-font-weight-bold); }
|
||||
.hm-orgchart-affiliation-bar .hm-orgchart-affiliation-mark {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: 0 0 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 20px;
|
||||
font-size: 9px;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
@import './design-tokens.css';
|
||||
@import './employee-card.css';
|
||||
@import './summary-sections.css';
|
||||
@import './org-chart-content.css';
|
||||
@import './org-chart-readonly.css';
|
||||
@import './org-chart-p3-summary.css';
|
||||
@import './employee-info-dialog.css';
|
||||
/* Standalone defaults are scoped to the shell; only OrgChart public variables
|
||||
inherit across the Shadow DOM boundary. No host HM Theme is required. */
|
||||
:host { display: block; min-width: 0; max-width: 100%; font: inherit; }
|
||||
.hm-orgchart-shell { position: relative; min-width: 0; color: var(--hm-orgchart-resolved-fg-primary); font-family: var(--hm-orgchart-resolved-font-family); font-size: var(--hm-orgchart-resolved-font-size-base); line-height: 1.5; }
|
||||
.hm-orgchart-shell *, .hm-orgchart-shell *::before, .hm-orgchart-shell *::after { box-sizing: border-box; }
|
||||
.hm-orgchart-state:empty { display: none; }
|
||||
.hm-orgchart-state, .hm-orgchart-issues { padding: 12px; background: var(--hm-orgchart-resolved-bg-content); border-radius: var(--hm-orgchart-resolved-radius-container); }
|
||||
.hm-orgchart-heading { margin-bottom: 16px; }
|
||||
.hm-orgchart-title { margin: 0; font-size: var(--hm-orgchart-resolved-font-size-title); line-height: 1.4; font-weight: var(--hm-orgchart-resolved-font-weight-bold); }
|
||||
.hm-orgchart-description, .hm-orgchart-date { margin: 4px 0; color: var(--hm-orgchart-resolved-fg-secondary); overflow-wrap: anywhere; }
|
||||
.hm-orgchart-viewport { width: 100%; max-width: 100%; min-width: 0; overflow: auto; padding: 8px 0; border: 1px solid var(--hm-orgchart-resolved-stroke-default); border-radius: var(--hm-orgchart-resolved-radius-container); }
|
||||
.hm-orgchart-viewport:focus-visible, .hm-orgchart-toggle:focus-visible { outline: 2px solid var(--hm-orgchart-resolved-stroke-input-focus); outline-offset: 2px; }
|
||||
.hm-orgchart-forest { display: flex; width: max-content; min-width: 100%; }
|
||||
.hm-orgchart-tree { flex: 0 0 auto; }
|
||||
.hm-orgchart-engine.orgchart { min-width: 100%; background-image: none; user-select: text; padding: 20px 16px 0; }
|
||||
.hm-orgchart-engine.orgchart .node { width: 244px; margin-right: 8px; margin-left: 8px; padding: 0; border: 0; background: none; text-align: left; }
|
||||
.hm-orgchart-engine.orgchart .node:hover, .hm-orgchart-engine.orgchart .node.focused { background: none; }
|
||||
.hm-orgchart-engine.orgchart .node .edge { display: none; }
|
||||
.hm-orgchart-engine.orgchart .hierarchy { text-align: center; }
|
||||
.hm-orgchart-engine.orgchart .hierarchy::before { border-color: var(--hm-orgchart-resolved-stroke-default); }
|
||||
.hm-orgchart-engine.orgchart > ul > li > ul li > .node::before { background-color: var(--hm-orgchart-resolved-stroke-default); }
|
||||
.hm-orgchart-engine.orgchart .hierarchy:not(.couple) > .node:has(+ .nodes)::after { background-color: var(--hm-orgchart-resolved-stroke-default); }
|
||||
.hm-orgchart-organization { width: 244px; padding: 12px; border: 1px solid var(--hm-orgchart-resolved-stroke-default); border-radius: var(--hm-orgchart-resolved-radius-container); background: var(--hm-orgchart-resolved-bg-surface); }
|
||||
.hm-orgchart-kind { display: block; font-size: 11px; color: var(--hm-orgchart-resolved-fg-secondary); }
|
||||
.hm-orgchart-organization-name { margin: 2px 0 0; font-size: 15px; line-height: 22px; overflow-wrap: anywhere; }
|
||||
.hm-orgchart-domain-id { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--hm-orgchart-resolved-fg-secondary); }
|
||||
.hm-orgchart-node-count, .hm-orgchart-vacancy { margin: 8px 0; font-size: 11px; color: var(--hm-orgchart-resolved-fg-secondary); }
|
||||
.hm-orgchart-employee { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
.hm-orgchart-leader { padding: 8px; background: var(--hm-orgchart-resolved-bg-content); border-radius: var(--hm-orgchart-resolved-radius-container); }
|
||||
.hm-orgchart-avatar { position: relative; display: flex; flex: 0 0 32px; width: 32px; height: 32px; align-items: center; justify-content: center; border-radius: 50%; overflow: hidden; background: var(--hm-orgchart-resolved-bg-subtle); color: var(--hm-orgchart-resolved-fg-secondary); }
|
||||
.hm-orgchart-avatar img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
.hm-orgchart-employee-details { min-width: 0; }
|
||||
.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-status-label { display: block; overflow-wrap: anywhere; }
|
||||
.hm-orgchart-person-role, .hm-orgchart-status-label { font-size: 11px; color: var(--hm-orgchart-resolved-fg-secondary); }
|
||||
.hm-orgchart-toggle { display: block; width: 100%; margin-top: 12px; padding: 6px 8px; border: 1px solid var(--hm-orgchart-resolved-stroke-default); border-radius: var(--hm-orgchart-resolved-radius-container); background: var(--hm-orgchart-resolved-bg-surface); color: var(--hm-orgchart-resolved-fg-primary); font: inherit; cursor: pointer; }
|
||||
.hm-orgchart-summary { margin-top: 16px; min-width: 0; }
|
||||
.hm-orgchart-summary-title { margin: 0 0 8px; font-size: 15px; }
|
||||
.hm-orgchart-raw-total, .hm-orgchart-policy { margin: 4px 0; }
|
||||
.hm-orgchart-policy { color: var(--hm-orgchart-resolved-fg-secondary); }
|
||||
.hm-orgchart-summary table { width: 100%; border-collapse: collapse; table-layout: fixed; margin-top: 12px; }
|
||||
.hm-orgchart-summary caption { padding-bottom: 8px; text-align: left; color: var(--hm-orgchart-resolved-fg-secondary); }
|
||||
.hm-orgchart-summary th, .hm-orgchart-summary td { padding: 8px; border-bottom: 1px solid var(--hm-orgchart-resolved-stroke-default); text-align: left; overflow-wrap: anywhere; }
|
||||
.hm-orgchart-summary th { font-weight: 500; }
|
||||
.hm-orgchart-group-title { margin: 12px 0 4px; font-size: 13px; }
|
||||
.hm-orgchart-summary-group { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 16px; margin: 0; }
|
||||
.hm-orgchart-summary-group dt { overflow-wrap: anywhere; }
|
||||
.hm-orgchart-summary-group dd { margin: 0; }
|
||||
@media (prefers-reduced-motion: reduce) { .hm-orgchart-engine.orgchart .node { transition-duration: 0.01ms; } }
|
||||
@@ -0,0 +1,141 @@
|
||||
/* Independent content summaries inside Core's Shadow DOM. Colors and radii use
|
||||
the standalone OrgChart defaults in design-tokens.css.
|
||||
Content gaps/dimensions are local geometry, not overrides of HM components. */
|
||||
.hm-orgchart-position-summary,
|
||||
.hm-orgchart-affiliation-summary,
|
||||
.hm-orgchart-status-group {
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--hm-orgchart-resolved-stroke-default);
|
||||
border-radius: var(--hm-orgchart-resolved-radius-container);
|
||||
background: var(--hm-orgchart-resolved-bg-surface);
|
||||
}
|
||||
.hm-orgchart-position-summary { margin-top: 0; }
|
||||
.hm-orgchart-position-summary .hm-orgchart-summary-title,
|
||||
.hm-orgchart-affiliation-summary .hm-orgchart-summary-title,
|
||||
.hm-orgchart-status-summary .hm-orgchart-summary-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 15px;
|
||||
font-weight: var(--hm-orgchart-resolved-font-weight-bold);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.hm-orgchart-position-summary .hm-orgchart-raw-total,
|
||||
.hm-orgchart-affiliation-summary .hm-orgchart-raw-total {
|
||||
margin: 0 0 8px;
|
||||
font-weight: var(--hm-orgchart-resolved-font-weight-medium);
|
||||
}
|
||||
.hm-orgchart-position-summary .hm-orgchart-policy,
|
||||
.hm-orgchart-status-summary .hm-orgchart-policy {
|
||||
margin: 4px 0;
|
||||
color: var(--hm-orgchart-resolved-fg-secondary);
|
||||
font-size: 11px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.hm-orgchart-summary-scroll { max-width: 100%; overflow: auto; margin-top: 12px; }
|
||||
.hm-orgchart-summary-scroll:focus-visible {
|
||||
outline: 2px solid var(--hm-orgchart-resolved-stroke-input-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.hm-orgchart-summary .hm-orgchart-position-table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
margin-top: 0;
|
||||
table-layout: auto;
|
||||
border-collapse: collapse;
|
||||
color: var(--hm-orgchart-resolved-table-text);
|
||||
background: var(--hm-orgchart-resolved-table-background);
|
||||
font-size: var(--hm-orgchart-resolved-table-font-size);
|
||||
line-height: var(--hm-orgchart-resolved-table-line-height);
|
||||
}
|
||||
.hm-orgchart-summary .hm-orgchart-position-table caption {
|
||||
max-width: 480px;
|
||||
padding: 0 0 var(--hm-orgchart-resolved-table-caption-gap);
|
||||
text-align: left;
|
||||
color: var(--hm-orgchart-resolved-table-caption-color);
|
||||
font-size: var(--hm-orgchart-resolved-table-caption-font-size);
|
||||
line-height: var(--hm-orgchart-resolved-table-caption-line-height);
|
||||
}
|
||||
.hm-orgchart-summary .hm-orgchart-position-table th,
|
||||
.hm-orgchart-summary .hm-orgchart-position-table td {
|
||||
min-width: 64px;
|
||||
max-width: 160px;
|
||||
padding: var(--hm-orgchart-resolved-table-cell-padding-y) var(--hm-orgchart-resolved-table-cell-padding-x);
|
||||
border-bottom: var(--hm-orgchart-resolved-table-divider-width) solid var(--hm-orgchart-resolved-table-divider-color);
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.hm-orgchart-summary .hm-orgchart-position-table th:first-child { min-width: 100px; }
|
||||
.hm-orgchart-summary .hm-orgchart-position-table thead th {
|
||||
background: var(--hm-orgchart-resolved-table-header-background);
|
||||
color: var(--hm-orgchart-resolved-table-header-text);
|
||||
border-top: var(--hm-orgchart-resolved-table-divider-width) solid var(--hm-orgchart-resolved-table-header-border-top);
|
||||
border-bottom-color: var(--hm-orgchart-resolved-table-header-border-bottom);
|
||||
font-size: var(--hm-orgchart-resolved-table-header-font-size);
|
||||
font-weight: var(--hm-orgchart-resolved-table-header-font-weight);
|
||||
}
|
||||
.hm-orgchart-summary .hm-orgchart-position-table tbody th { font-weight: var(--hm-orgchart-resolved-font-weight-medium); }
|
||||
.hm-orgchart-summary .hm-orgchart-position-table .hm-orgchart-count-cell { text-align: right; }
|
||||
.hm-orgchart-summary .hm-orgchart-position-table .hm-orgchart-subtotal { font-weight: var(--hm-orgchart-resolved-font-weight-bold); }
|
||||
.hm-orgchart-summary .hm-orgchart-position-table .hm-orgchart-inclusive { color: var(--hm-orgchart-resolved-fg-secondary); text-align: right; }
|
||||
.hm-orgchart-summary .hm-orgchart-position-table tfoot {
|
||||
background: var(--hm-orgchart-resolved-table-footer-background);
|
||||
font-weight: var(--hm-orgchart-resolved-table-footer-font-weight);
|
||||
}
|
||||
.hm-orgchart-summary .hm-orgchart-position-table tbody tr:hover { background: var(--hm-orgchart-resolved-table-row-hover-background); }
|
||||
.hm-orgchart-status-totals { font-size: 12px; }
|
||||
.hm-orgchart-affiliation-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 160px), 1fr));
|
||||
gap: 8px;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.hm-orgchart-affiliation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--hm-orgchart-resolved-stroke-input-default);
|
||||
border-radius: var(--hm-orgchart-resolved-radius-container);
|
||||
background: var(--hm-orgchart-resolved-bg-content);
|
||||
}
|
||||
.hm-orgchart-affiliation-name { min-width: 0; overflow-wrap: anywhere; font-weight: var(--hm-orgchart-resolved-font-weight-medium); }
|
||||
.hm-orgchart-affiliation-name { display: flex; align-items: center; gap: 8px; }
|
||||
.hm-orgchart-affiliation-name .hm-orgchart-affiliation-mark {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 28px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.hm-orgchart-affiliation-count { flex: 0 0 auto; margin: 0; font-variant-numeric: tabular-nums; font-weight: var(--hm-orgchart-resolved-font-weight-bold); }
|
||||
.hm-orgchart-status-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.hm-orgchart-status-summary:empty { display: none; }
|
||||
.hm-orgchart-status-employees { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px; margin-bottom: 12px; }
|
||||
.hm-orgchart-status-employees .hm-orgchart-employee { margin-top: 0; }
|
||||
.hm-orgchart-status-group > .hm-orgchart-summary-title {
|
||||
background: linear-gradient(180deg, #87909c 0%, #68727e 100%);
|
||||
border: 1px solid rgba(92, 101, 112, 0.52);
|
||||
box-shadow: 0 7px 16px rgba(75, 84, 96, 0.18);
|
||||
}
|
||||
.hm-orgchart-status-group .hm-orgchart-employee[data-card-kind] {
|
||||
margin: 0;
|
||||
background-image: linear-gradient(180deg, #f4f5f6 0%, #dfe3e7 100%) !important;
|
||||
border: 1px solid rgba(120, 130, 142, 0.35);
|
||||
border-radius: var(--br-3_5, var(--hm-orgchart-resolved-radius-container));
|
||||
cursor: pointer;
|
||||
}
|
||||
.hm-orgchart-status-group .hm-orgchart-employee[data-card-kind] .hm-orgchart-person-role { color: #555f6a; }
|
||||
.hm-orgchart-status-group .hm-orgchart-employee[data-card-kind] .hm-orgchart-person-name { color: #3e4650; }
|
||||
.hm-orgchart-status-group .hm-orgchart-affiliation-mark img { filter: grayscale(1); opacity: 0.72; }
|
||||
.hm-orgchart-content-empty { margin: 8px 0; color: var(--hm-orgchart-resolved-fg-secondary); font-size: 12px; }
|
||||
@@ -0,0 +1,17 @@
|
||||
import { build } from 'vite';
|
||||
import { globSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
const root = fileURLToPath(new URL('./', import.meta.url));
|
||||
// Optional exact paths let a change run only its affected tests; default keeps
|
||||
// the existing full data test command and discovery unchanged.
|
||||
const selected = process.argv.slice(2).map(file => file.replaceAll('\\', '/'));
|
||||
const tests = [...globSync('src/**/*.test.ts', { cwd: root })].filter(file => !selected.length || selected.includes(file.replaceAll('\\', '/')));
|
||||
if (!tests.length) throw new Error('No OrgChart data tests found');
|
||||
await build({ configFile: false, root, publicDir: false, build: {
|
||||
outDir: '../../test-results/org-chart-unit', emptyOutDir: true, target: 'node22', minify: false,
|
||||
lib: { entry: Object.fromEntries(tests.map((file, i) => [`test-${i}`, path.join(root, file)])), formats: ['es'], fileName: (_, entry) => `${entry}.mjs` },
|
||||
rolldownOptions: { external: [/^node:/] },
|
||||
} });
|
||||
execFileSync(process.execPath, ['--test', ...tests.map((_, i) => path.join(root, '../../test-results/org-chart-unit', `test-${i}.mjs`))], { stdio: 'inherit' });
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": false,
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||