From 32e843c614175993be6a13fbe26d78cc4d8c66cf Mon Sep 17 00:00:00 2001 From: Kaz Wesley Date: Wed, 10 Jul 2024 14:04:37 -0700 Subject: [PATCH] Expose backend API to IDE (#10442) Move `Backend` and supporting APIs from `dashboard` to `enso-common`. Closes #10400. # Important Notes - The utility modules required by `Backend` have been moved to `enso-common`. Those defining general-purpose helpers for working with standard types are now in a submodule `utilities/data` to match the IDE organization; in the future we can merge them with the `util/data` gui2 subtree. Moved utilities are reexported from their dashboard locations, so that moved and not-yet-moved modules can be imported consistently. - The `text` module has been moved to `enso-common`; the IDE doesn't have any localization mechanism yet, so we can share this one. --- app/ide-desktop/lib/common/package.json | 9 +- .../lib/common/src/services/Backend.ts | 1473 ++++++++++++++++ .../src/text/english.json | 0 app/ide-desktop/lib/common/src/text/index.ts | 136 ++ .../lib/common/src/utilities/data/array.ts | 65 + .../lib/common/src/utilities/data/dateTime.ts | 78 + .../lib/common/src/utilities/data/newtype.ts | 64 + .../lib/common/src/utilities/permissions.ts | 248 +++ .../lib/common/src/utilities/uniqueString.ts | 14 + app/ide-desktop/lib/common/tsconfig.json | 2 +- .../dashboard/src/components/MenuEntry.tsx | 3 +- .../components/PaywallBulletPoints.tsx | 3 +- .../components/dashboard/KeyboardShortcut.tsx | 3 +- .../src/components/dashboard/Permission.tsx | 2 +- .../dashboard/column/columnUtils.ts | 3 +- .../hooks/billing/FeaturesConfiguration.ts | 2 +- .../dashboard/src/hooks/toastAndLogHooks.ts | 2 +- .../src/layouts/CategorySwitcher.tsx | 3 +- .../src/layouts/Settings/settingsData.tsx | 3 +- .../lib/dashboard/src/layouts/TabBar.tsx | 2 +- .../lib/dashboard/src/modals/AboutModal.tsx | 3 +- .../subscribe/Subscribe/components/Card.tsx | 3 +- .../Subscribe/getComponentForPlan.tsx | 2 +- .../src/pages/subscribe/constants.ts | 2 +- .../dashboard/src/providers/TextProvider.tsx | 2 +- .../lib/dashboard/src/services/Backend.ts | 1474 +---------------- .../dashboard/src/services/RemoteBackend.ts | 3 +- .../lib/dashboard/src/text/index.ts | 136 -- .../lib/dashboard/src/utilities/array.ts | 64 +- .../lib/dashboard/src/utilities/dateTime.ts | 77 +- .../lib/dashboard/src/utilities/newtype.ts | 63 +- .../dashboard/src/utilities/permissions.ts | 247 +-- .../dashboard/src/utilities/uniqueString.ts | 13 +- 33 files changed, 2110 insertions(+), 2094 deletions(-) create mode 100644 app/ide-desktop/lib/common/src/services/Backend.ts rename app/ide-desktop/lib/{dashboard => common}/src/text/english.json (100%) create mode 100644 app/ide-desktop/lib/common/src/text/index.ts create mode 100644 app/ide-desktop/lib/common/src/utilities/data/array.ts create mode 100644 app/ide-desktop/lib/common/src/utilities/data/dateTime.ts create mode 100644 app/ide-desktop/lib/common/src/utilities/data/newtype.ts create mode 100644 app/ide-desktop/lib/common/src/utilities/permissions.ts create mode 100644 app/ide-desktop/lib/common/src/utilities/uniqueString.ts delete mode 100644 app/ide-desktop/lib/dashboard/src/text/index.ts diff --git a/app/ide-desktop/lib/common/package.json b/app/ide-desktop/lib/common/package.json index 8a5a5b7eee..dad074fcbd 100644 --- a/app/ide-desktop/lib/common/package.json +++ b/app/ide-desktop/lib/common/package.json @@ -11,7 +11,14 @@ "./src/detect": "./src/detect.ts", "./src/gtag": "./src/gtag.ts", "./src/load": "./src/load.ts", - "./src/queryClient": "./src/queryClient.ts" + "./src/queryClient": "./src/queryClient.ts", + "./src/utilities/data/array": "./src/utilities/data/array.ts", + "./src/utilities/data/dateTime": "./src/utilities/data/dateTime.ts", + "./src/utilities/data/newtype": "./src/utilities/data/newtype.ts", + "./src/utilities/uniqueString": "./src/utilities/uniqueString.ts", + "./src/text": "./src/text/index.ts", + "./src/utilities/permissions": "./src/utilities/permissions.ts", + "./src/services/Backend": "./src/services/Backend.ts" }, "peerDependencies": { "@tanstack/query-core": "5.45.0", diff --git a/app/ide-desktop/lib/common/src/services/Backend.ts b/app/ide-desktop/lib/common/src/services/Backend.ts new file mode 100644 index 0000000000..23c69b5acd --- /dev/null +++ b/app/ide-desktop/lib/common/src/services/Backend.ts @@ -0,0 +1,1473 @@ +/** @file Type definitions common between all backends. */ + +import * as array from '../utilities/data/array' +import * as dateTime from '../utilities/data/dateTime' +import * as newtype from '../utilities/data/newtype' +import * as permissions from '../utilities/permissions' +import * as uniqueString from '../utilities/uniqueString' + +// ================ +// === Newtypes === +// ================ + +// These are constructor functions that construct values of the type they are named after. +/* eslint-disable @typescript-eslint/no-redeclare */ + +/** Unique identifier for an organization. */ +export type OrganizationId = newtype.Newtype +export const OrganizationId = newtype.newtypeConstructor() + +/** Unique identifier for a user in an organization. */ +export type UserId = newtype.Newtype +export const UserId = newtype.newtypeConstructor() + +/** Unique identifier for a user group. */ +export type UserGroupId = newtype.Newtype +export const UserGroupId = newtype.newtypeConstructor() + +/** Unique identifier for a directory. */ +export type DirectoryId = newtype.Newtype +export const DirectoryId = newtype.newtypeConstructor() + +/** Unique identifier for an asset representing the items inside a directory for which the + * request to retrive the items has not yet completed. */ +export type LoadingAssetId = newtype.Newtype +export const LoadingAssetId = newtype.newtypeConstructor() + +/** Unique identifier for an asset representing the nonexistent children of an empty directory. */ +export type EmptyAssetId = newtype.Newtype +export const EmptyAssetId = newtype.newtypeConstructor() + +/** Unique identifier for a user's project. */ +export type ProjectId = newtype.Newtype +export const ProjectId = newtype.newtypeConstructor() + +/** Unique identifier for an uploaded file. */ +export type FileId = newtype.Newtype +export const FileId = newtype.newtypeConstructor() + +/** Unique identifier for a secret environment variable. */ +export type SecretId = newtype.Newtype +export const SecretId = newtype.newtypeConstructor() + +/** Unique identifier for a project session. */ +export type ProjectSessionId = newtype.Newtype +export const ProjectSessionId = newtype.newtypeConstructor() + +/** Unique identifier for a Datalink. */ +export type DatalinkId = newtype.Newtype +export const DatalinkId = newtype.newtypeConstructor() + +/** Unique identifier for a version of an S3 object. */ +export type S3ObjectVersionId = newtype.Newtype +export const S3ObjectVersionId = newtype.newtypeConstructor() + +/** Unique identifier for an arbitrary asset. */ +export type AssetId = IdType[keyof IdType] + +/** Unique identifier for a payment checkout session. */ +export type CheckoutSessionId = newtype.Newtype +export const CheckoutSessionId = newtype.newtypeConstructor() + +/** + * Unique identifier for a subscription. + */ +export type SubscriptionId = newtype.Newtype +export const SubscriptionId = newtype.newtypeConstructor() + +/** The name of an asset label. */ +export type LabelName = newtype.Newtype +export const LabelName = newtype.newtypeConstructor() + +/** Unique identifier for a label. */ +export type TagId = newtype.Newtype +export const TagId = newtype.newtypeConstructor() + +/** A URL. */ +export type Address = newtype.Newtype +export const Address = newtype.newtypeConstructor
() + +/** A HTTPS URL. */ +export type HttpsUrl = newtype.Newtype +export const HttpsUrl = newtype.newtypeConstructor() + +/** An email address. */ +export type EmailAddress = newtype.Newtype +export const EmailAddress = newtype.newtypeConstructor() + +/** An AWS S3 file path. */ +export type S3FilePath = newtype.Newtype +export const S3FilePath = newtype.newtypeConstructor() + +/** An AWS machine configuration. */ +export type Ami = newtype.Newtype +export const Ami = newtype.newtypeConstructor() + +/** An identifier for an entity with an {@link AssetPermission} for an {@link Asset}. */ +export type UserPermissionIdentifier = UserGroupId | UserId + +/** An filesystem path. Only present on the local backend. */ +export type Path = newtype.Newtype +export const Path = newtype.newtypeConstructor() + +/* eslint-enable @typescript-eslint/no-redeclare */ + +/** Whether a given {@link string} is an {@link UserId}. */ +export function isUserId(id: string): id is UserId { + return id.startsWith('user-') +} + +/** Whether a given {@link string} is an {@link UserGroupId}. */ +export function isUserGroupId(id: string): id is UserGroupId { + return id.startsWith('usergroup-') +} + +const PLACEHOLDER_USER_GROUP_PREFIX = 'usergroup-placeholder-' + +/** Whether a given {@link UserGroupId} represents a user group that does not yet exist on the + * server. */ +export function isPlaceholderUserGroupId(id: string) { + return id.startsWith(PLACEHOLDER_USER_GROUP_PREFIX) +} + +/** Return a new {@link UserGroupId} that represents a placeholder user group that is yet to finish + * being created on the backend. */ +export function newPlaceholderUserGroupId() { + return UserGroupId(`${PLACEHOLDER_USER_GROUP_PREFIX}${uniqueString.uniqueString()}`) +} + +// ============= +// === Types === +// ============= + +/** The {@link Backend} variant. If a new variant is created, it should be added to this enum. */ +export enum BackendType { + local = 'local', + remote = 'remote', +} + +/** Metadata uniquely identifying a user inside an organization. */ +export interface UserInfo { + /** The ID of the parent organization. If this is a sole user, they are implicitly in an + * organization consisting of only themselves. */ + readonly organizationId: OrganizationId + /** The name of the parent organization. */ + readonly organizationName?: string + /** The ID of this user. + * + * The user ID is globally unique. Thus, the user ID is always sufficient to uniquely identify a + * user. The user ID is guaranteed to never change, once assigned. For these reasons, the user ID + * should be the preferred way to uniquely refer to a user. That is, when referring to a user, + * prefer this field over `name`, `email`, `subject`, or any other mechanism, where possible. */ + readonly userId: UserId + readonly name: string + readonly email: EmailAddress +} + +/** A user in the application. These are the primary owners of a project. */ +export interface User extends UserInfo { + /** If `false`, this account is awaiting acceptance from an administrator, and endpoints other than + * `usersMe` will not work. */ + readonly isEnabled: boolean + readonly rootDirectoryId: DirectoryId + readonly profilePicture?: HttpsUrl + readonly userGroups: readonly UserGroupId[] | null + readonly removeAt?: dateTime.Rfc3339DateTime | null + readonly plan?: Plan | undefined +} + +/** A `Directory` returned by `createDirectory`. */ +export interface CreatedDirectory { + readonly id: DirectoryId + readonly parentId: DirectoryId + readonly title: string +} + +/** Possible states that a project can be in. */ +export enum ProjectState { + created = 'Created', + new = 'New', + scheduled = 'Scheduled', + openInProgress = 'OpenInProgress', + provisioned = 'Provisioned', + opened = 'Opened', + closed = 'Closed', + /** A frontend-specific state, representing a project that should be displayed as + * `openInProgress`, but has not yet been added to the backend. */ + placeholder = 'Placeholder', + /** A frontend-specific state, representing a project that should be displayed as `closed`, + * but is still in the process of shutting down. */ + closing = 'Closing', +} + +/** Wrapper around a project state value. */ +export interface ProjectStateType { + readonly type: ProjectState + readonly volumeId: string + readonly instanceId?: string + readonly executeAsync?: boolean + readonly address?: string + readonly securityGroupId?: string + readonly ec2Id?: string + readonly ec2PublicIpAddress?: string + readonly currentSessionId?: string + readonly openedBy?: EmailAddress + /** Only present on the Local backend. */ + readonly path?: Path +} + +export const IS_OPENING: Readonly> = { + [ProjectState.created]: false, + [ProjectState.new]: false, + [ProjectState.scheduled]: true, + [ProjectState.openInProgress]: true, + [ProjectState.provisioned]: true, + [ProjectState.opened]: false, + [ProjectState.closed]: false, + [ProjectState.placeholder]: true, + [ProjectState.closing]: false, +} + +export const IS_OPENING_OR_OPENED: Readonly> = { + [ProjectState.created]: false, + [ProjectState.new]: false, + [ProjectState.scheduled]: true, + [ProjectState.openInProgress]: true, + [ProjectState.provisioned]: true, + [ProjectState.opened]: true, + [ProjectState.closed]: false, + [ProjectState.placeholder]: true, + [ProjectState.closing]: false, +} + +/** Common `Project` fields returned by all `Project`-related endpoints. */ +export interface BaseProject { + readonly organizationId: string + readonly projectId: ProjectId + readonly name: string +} + +/** A `Project` returned by `createProject`. */ +export interface CreatedProject extends BaseProject { + readonly state: ProjectStateType + readonly packageName: string +} + +/** A `Project` returned by the `listProjects` endpoint. */ +export interface ListedProjectRaw extends CreatedProject { + readonly address?: Address +} + +/** A `Project` returned by `listProjects`. */ +export interface ListedProject extends CreatedProject { + readonly binaryAddress: Address | null + readonly jsonAddress: Address | null +} + +/** A `Project` returned by `updateProject`. */ +export interface UpdatedProject extends BaseProject { + readonly ami: Ami | null + readonly ideVersion: VersionNumber | null + readonly engineVersion: VersionNumber | null +} + +/** A user/organization's project containing and/or currently executing code. */ +export interface ProjectRaw extends ListedProjectRaw { + // eslint-disable-next-line @typescript-eslint/naming-convention + readonly ide_version: VersionNumber | null + // eslint-disable-next-line @typescript-eslint/naming-convention + readonly engine_version: VersionNumber | null +} + +/** A user/organization's project containing and/or currently executing code. */ +export interface Project extends ListedProject { + readonly ideVersion: VersionNumber | null + readonly engineVersion: VersionNumber | null + readonly openedBy?: EmailAddress + /** On the Remote (Cloud) Backend, this is a S3 url that is valid for only 120 seconds. */ + readonly url?: HttpsUrl +} + +/** A user/organization's project containing and/or currently executing code. */ +export interface BackendProject extends Project { + /** This must not be null as it is required to determine the base URL for backend assets. */ + readonly ideVersion: VersionNumber +} + +/** A specific session of a project being opened and used. */ +export interface ProjectSession { + readonly projectId: ProjectId + readonly projectSessionId: ProjectSessionId + readonly createdAt: dateTime.Rfc3339DateTime + readonly closedAt?: dateTime.Rfc3339DateTime + readonly userEmail: EmailAddress +} + +/** Metadata describing the location of an uploaded file. */ +export interface FileLocator { + readonly fileId: FileId + readonly fileName: string | null + readonly path: S3FilePath +} + +/** Metadata uniquely identifying an uploaded file. */ +export interface FileInfo { + /* TODO: Should potentially be S3FilePath, + * but it's just string on the backend. */ + readonly path: string + readonly id: FileId + readonly project: CreatedProject | null +} + +/** Metadata for a file. */ +export interface FileMetadata { + readonly size: number +} + +/** All metadata related to a file. */ +export interface FileDetails { + readonly file: FileLocator + readonly metadata: FileMetadata + /** On the Remote (Cloud) Backend, this is a S3 url that is valid for only 120 seconds. */ + readonly url?: string +} + +/** A secret environment variable. */ +export interface Secret { + readonly id: SecretId + readonly value: string +} + +/** A secret environment variable and metadata uniquely identifying it. */ +export interface SecretAndInfo { + readonly id: SecretId + readonly name: string + readonly value: string +} + +/** Metadata uniquely identifying a secret environment variable. */ +export interface SecretInfo { + readonly name: string + readonly id: SecretId + readonly path: string +} + +/** A Datalink. */ +export type Datalink = newtype.Newtype + +/** Metadata uniquely identifying a Datalink. */ +export interface DatalinkInfo { + readonly id: DatalinkId +} + +/** A label. */ +export interface Label { + readonly id: TagId + readonly value: LabelName + readonly color: LChColor +} + +/** Type of application that a {@link Version} applies to. + * + * We keep track of both backend and IDE versions, so that we can update the two independently. + * However the format of the version numbers is the same for both, so we can use the same type for + * both. We just need this enum to disambiguate. */ +export enum VersionType { + backend = 'Backend', + ide = 'Ide', +} + +/** Stability of an IDE or backend version. */ +export enum VersionLifecycle { + stable = 'Stable', + releaseCandidate = 'ReleaseCandidate', + nightly = 'Nightly', + development = 'Development', +} + +/** Version number of an IDE or backend. */ +export interface VersionNumber { + readonly value: string + readonly lifecycle: VersionLifecycle +} + +/** A version describing a release of the backend or IDE. */ +export interface Version { + readonly number: VersionNumber + readonly ami: Ami | null + readonly created: dateTime.Rfc3339DateTime + // This does not follow our naming convention because it's defined this way in the backend, + // so we need to match it. + // eslint-disable-next-line @typescript-eslint/naming-convention + readonly version_type: VersionType +} + +/** Credentials that need to be passed to libraries to give them access to the Cloud API. */ +export interface CognitoCredentials { + readonly accessToken: string + readonly refreshToken: string + readonly refreshUrl: string + readonly clientId: string + readonly expireAt: dateTime.Rfc3339DateTime +} + +/** Subscription plans. */ +export enum Plan { + solo = 'solo', + team = 'team', + enterprise = 'enterprise', +} + +export const PLANS = Object.values(Plan) + +// This is a function, even though it does not look like one. +// eslint-disable-next-line no-restricted-syntax +export const isPlan = array.includesPredicate(PLANS) + +/** Metadata uniquely describing a payment checkout session. */ +export interface CheckoutSession { + /** ID of the checkout session, suffixed with a secret value. */ + readonly clientSecret: string + /** ID of the checkout session. */ + readonly id: CheckoutSessionId +} + +/** Metadata describing the status of a payment checkout session. */ +export interface CheckoutSessionStatus { + /** Status of the payment for the checkout session. */ + readonly paymentStatus: string + /** Status of the checkout session. */ + readonly status: string +} + +/** Resource usage of a VM. */ +export interface ResourceUsage { + /** Percentage of memory used. */ + readonly memory: number + /** Percentage of CPU time used since boot. */ + readonly cpu: number + /** Percentage of disk space used. */ + readonly storage: number +} + +/** + * Metadata for a subscription. + */ +export interface Subscription { + readonly id?: SubscriptionId + readonly plan?: Plan + readonly trialStart?: dateTime.Rfc3339DateTime | null + readonly trialEnd?: dateTime.Rfc3339DateTime | null +} + +/** Metadata for an organization. */ +export interface OrganizationInfo { + readonly id: OrganizationId + readonly name: string | null + readonly email: EmailAddress | null + readonly website: HttpsUrl | null + readonly address: string | null + readonly picture: HttpsUrl | null + readonly subscription: Subscription +} + +/** A user group and its associated metadata. */ +export interface UserGroupInfo { + readonly organizationId: OrganizationId + readonly id: UserGroupId + readonly groupName: string +} + +/** User permission for a specific user. */ +export interface UserPermission { + readonly user: UserInfo + readonly permission: permissions.PermissionAction +} + +/** User permission for a specific user group. */ +export interface UserGroupPermission { + readonly userGroup: UserGroupInfo + readonly permission: permissions.PermissionAction +} + +/** User permission for a specific user or user group. */ +export type AssetPermission = UserGroupPermission | UserPermission + +/** Whether an {@link AssetPermission} is a {@link UserPermission}. */ +export function isUserPermission(permission: AssetPermission): permission is UserPermission { + return 'user' in permission +} + +/** Whether an {@link AssetPermission} is a {@link UserPermission} with an additional predicate. */ +export function isUserPermissionAnd(predicate: (permission: UserPermission) => boolean) { + return (permission: AssetPermission): permission is UserPermission => + isUserPermission(permission) && predicate(permission) +} + +/** Whether an {@link AssetPermission} is a {@link UserGroupPermission}. */ +export function isUserGroupPermission( + permission: AssetPermission +): permission is UserGroupPermission { + return 'userGroup' in permission +} + +/** Whether an {@link AssetPermission} is a {@link UserGroupPermission} with an additional predicate. */ +export function isUserGroupPermissionAnd(predicate: (permission: UserGroupPermission) => boolean) { + return (permission: AssetPermission): permission is UserGroupPermission => + isUserGroupPermission(permission) && predicate(permission) +} + +/** Get the property representing the name on an arbitrary variant of {@link UserPermission}. */ +export function getAssetPermissionName(permission: AssetPermission) { + return isUserPermission(permission) ? permission.user.name : permission.userGroup.groupName +} + +/** Get the property representing the id on an arbitrary variant of {@link UserPermission}. */ +export function getAssetPermissionId(permission: AssetPermission): UserPermissionIdentifier { + return isUserPermission(permission) ? permission.user.userId : permission.userGroup.id +} + +/** The type returned from the "update directory" endpoint. */ +export interface UpdatedDirectory { + readonly id: DirectoryId + readonly parentId: DirectoryId + readonly title: string +} + +/** The type returned from the "create directory" endpoint. */ +export interface Directory extends DirectoryAsset {} + +/** The subset of asset fields returned by the "copy asset" endpoint. */ +export interface CopiedAsset { + readonly id: AssetId + readonly parentId: DirectoryId + readonly title: string +} + +/** The type returned from the "copy asset" endpoint. */ +export interface CopyAssetResponse { + readonly asset: CopiedAsset +} + +/** Possible filters for the "list directory" endpoint. */ +export enum FilterBy { + all = 'All', + active = 'Active', + recent = 'Recent', + trashed = 'Trashed', +} + +/** An event in an audit log. */ +export interface Event { + readonly organizationId: OrganizationId + readonly userEmail: EmailAddress + readonly timestamp: dateTime.Rfc3339DateTime | null + // Called `EventKind` in the backend. + readonly metadata: EventMetadata +} + +/** Possible types of event in an audit log. */ +export enum EventType { + GetSecret = 'getSecret', + DeleteAssets = 'deleteAssets', + ListSecrets = 'listSecrets', + OpenProject = 'openProject', + UploadFile = 'uploadFile', +} + +export const EVENT_TYPES = Object.freeze(Object.values(EventType)) + +/** An event indicating that a secret was accessed. */ +interface GetSecretEventMetadata { + readonly type: EventType.GetSecret + readonly secretId: SecretId +} + +/** An event indicating that one or more assets were deleted. */ +interface DeleteAssetsEventMetadata { + readonly type: EventType.DeleteAssets +} + +/** An event indicating that all secrets were listed. */ +interface ListSecretsEventMetadata { + readonly type: EventType.ListSecrets +} + +/** An event indicating that a project was opened. */ +interface OpenProjectEventMetadata { + readonly type: EventType.OpenProject +} + +/** An event indicating that a file was uploaded. */ +interface UploadFileEventMetadata { + readonly type: EventType.UploadFile +} + +/** All possible types of metadata for an event in the audit log. */ +export type EventMetadata = + | DeleteAssetsEventMetadata + | GetSecretEventMetadata + | ListSecretsEventMetadata + | OpenProjectEventMetadata + | UploadFileEventMetadata + +/** A color in the LCh colorspace. */ +export interface LChColor { + readonly lightness: number + readonly chroma: number + readonly hue: number + readonly alpha?: number +} + +/** A pre-selected list of colors to be used in color pickers. */ +export const COLORS: readonly [LChColor, ...LChColor[]] = [ + /* eslint-disable @typescript-eslint/no-magic-numbers */ + // Red + { lightness: 50, chroma: 66, hue: 7 }, + // Orange + { lightness: 50, chroma: 66, hue: 34 }, + // Yellow + { lightness: 50, chroma: 66, hue: 80 }, + // Turquoise + { lightness: 50, chroma: 66, hue: 139 }, + // Teal + { lightness: 50, chroma: 66, hue: 172 }, + // Blue + { lightness: 50, chroma: 66, hue: 271 }, + // Lavender + { lightness: 50, chroma: 66, hue: 295 }, + // Pink + { lightness: 50, chroma: 66, hue: 332 }, + // Light blue + { lightness: 50, chroma: 22, hue: 252 }, + // Dark blue + { lightness: 22, chroma: 13, hue: 252 }, + /* eslint-enable @typescript-eslint/no-magic-numbers */ +] + +/** Converts a {@link LChColor} to a CSS color string. */ +export function lChColorToCssColor(color: LChColor): string { + const alpha = 'alpha' in color ? ` / ${color.alpha}` : '' + return `lch(${color.lightness}% ${color.chroma} ${color.hue}${alpha})` +} + +export const COLOR_STRING_TO_COLOR = new Map( + COLORS.map(color => [lChColorToCssColor(color), color]) +) + +export const INITIAL_COLOR_COUNTS = new Map(COLORS.map(color => [lChColorToCssColor(color), 0])) + +/** The color that is used for the least labels. Ties are broken by order. */ +export function leastUsedColor(labels: Iterable