Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support auto computing dark mode #1093

Open
wants to merge 3 commits into
base: v6
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions src/canvas/Painter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Layer, { LayerConfig } from './Layer';
import requestAnimationFrame from '../animation/requestAnimationFrame';
import env from '../core/env';
import Displayable from '../graphic/Displayable';
import { WXCanvasRenderingContext } from '../core/types';
import { Dictionary, WXCanvasRenderingContext } from '../core/types';
import { GradientObject } from '../graphic/Gradient';
import { ImagePatternObject } from '../graphic/Pattern';
import Storage from '../Storage';
Expand All @@ -14,6 +14,7 @@ import BoundingRect from '../core/BoundingRect';
import { REDRAW_BIT } from '../graphic/constants';
import { getSize } from './helper';
import type IncrementalDisplayable from '../graphic/IncrementalDisplayable';
import { convertToDark } from '../tool/color';

const HOVER_LAYER_ZLEVEL = 1e5;
const CANVAS_ZLEVEL = 314159;
Expand Down Expand Up @@ -67,7 +68,9 @@ interface CanvasPainterOption {
devicePixelRatio?: number
width?: number | string // Can be 10 / 10px / auto
height?: number | string,
useDirtyRect?: boolean
useDirtyRect?: boolean,
darkMode?: boolean,
darkColorMap?: Dictionary<string>
}

export default class CanvasPainter implements PainterBase {
Expand Down Expand Up @@ -274,7 +277,9 @@ export default class CanvasPainter implements PainterBase {
const scope: BrushScope = {
inHover: true,
viewWidth: this._width,
viewHeight: this._height
viewHeight: this._height,
darkMode: this._opts.darkMode,
darkColorMap: this._opts.darkColorMap
};

let ctx;
Expand Down Expand Up @@ -305,7 +310,7 @@ export default class CanvasPainter implements PainterBase {
}

paintOne(ctx: CanvasRenderingContext2D, el: Displayable) {
brushSingle(ctx, el);
brushSingle(ctx, el, this._opts.darkMode, this._opts.darkColorMap);
}

private _paintList(list: Displayable[], prevList: Displayable[], paintAll: boolean, redrawId?: number) {
Expand Down Expand Up @@ -416,7 +421,9 @@ export default class CanvasPainter implements PainterBase {
allClipped: false,
prevEl: null,
viewWidth: this._width,
viewHeight: this._height
viewHeight: this._height,
darkMode: this._opts.darkMode,
darkColorMap: this._opts.darkColorMap
};

for (i = start; i < layer.__endIndex; i++) {
Expand Down Expand Up @@ -785,7 +792,10 @@ export default class CanvasPainter implements PainterBase {
}

setBackgroundColor(backgroundColor: string | GradientObject | ImagePatternObject) {
this._backgroundColor = backgroundColor;
// TODO: fix when is gradient or pattern
this._backgroundColor = this._opts.darkMode
? convertToDark(backgroundColor as string, 'fill', this._opts.darkColorMap)
: backgroundColor;

util.each(this._layers, layer => {
layer.setUnpainted();
Expand Down Expand Up @@ -950,7 +960,9 @@ export default class CanvasPainter implements PainterBase {
const scope = {
inHover: false,
viewWidth: this._width,
viewHeight: this._height
viewHeight: this._height,
darkMode: this._opts.darkMode,
darkColorMap: this._opts.darkColorMap
};
const displayList = this.storage.getDisplayList(true);
for (let i = 0, len = displayList.length; i < len; i++) {
Expand Down
36 changes: 30 additions & 6 deletions src/canvas/graphic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { GradientObject } from '../graphic/Gradient';
import { ImagePatternObject, InnerImagePatternObject } from '../graphic/Pattern';
import { LinearGradientObject } from '../graphic/LinearGradient';
import { RadialGradientObject } from '../graphic/RadialGradient';
import { ZRCanvasRenderingContext } from '../core/types';
import { Dictionary, ZRCanvasRenderingContext } from '../core/types';
import { createOrUpdateImage, isImageReady } from '../graphic/helper/image';
import { getCanvasGradient, isClipPathChanged } from './helper';
import Path, { PathStyleProps } from '../graphic/Path';
Expand All @@ -16,6 +16,7 @@ import { getLineDash } from './dashStyle';
import { REDRAW_BIT, SHAPE_CHANGED_BIT } from '../graphic/constants';
import type IncrementalDisplayable from '../graphic/IncrementalDisplayable';
import { DEFAULT_FONT } from '../core/platform';
import { convertToDark } from '../tool/color';

const pathProxyForDraw = new PathProxy(true);

Expand Down Expand Up @@ -439,14 +440,20 @@ function bindPathAndTextCommonStyle(
flushPathDrawn(ctx, scope);
styleChanged = true;
}
isValidStrokeFillStyle(style.fill) && (ctx.fillStyle = style.fill);
isValidStrokeFillStyle(style.fill) && (ctx.fillStyle = scope.darkMode
? convertToDark(style.fill, 'fill', scope.darkColorMap)
: style.fill
);
}
if (forceSetAll || style.stroke !== prevStyle.stroke) {
if (!styleChanged) {
flushPathDrawn(ctx, scope);
styleChanged = true;
}
isValidStrokeFillStyle(style.stroke) && (ctx.strokeStyle = style.stroke);
isValidStrokeFillStyle(style.stroke) && (ctx.strokeStyle = scope.darkMode
? convertToDark(style.stroke, 'stroke', scope.darkColorMap)
: style.stroke
);
}
if (forceSetAll || style.opacity !== prevStyle.opacity) {
if (!styleChanged) {
Expand Down Expand Up @@ -566,6 +573,9 @@ export type BrushScope = {
batchStroke?: string

lastDrawType?: number

darkMode?: boolean
darkColorMap?: Dictionary<string>
}

// If path can be batched
Expand Down Expand Up @@ -602,8 +612,20 @@ function getStyle(el: Displayable, inHover?: boolean) {
return inHover ? (el.__hoverStyle || el.style) : el.style;
}

export function brushSingle(ctx: CanvasRenderingContext2D, el: Displayable) {
brush(ctx, el, { inHover: false, viewWidth: 0, viewHeight: 0 }, true);
export function brushSingle(
ctx: CanvasRenderingContext2D,
el: Displayable,
darkMode?: boolean,
darkColorMap?: Dictionary<string>
) {
const scope: BrushScope = {
inHover: false,
viewWidth: 0,
viewHeight: 0,
darkMode,
darkColorMap
};
brush(ctx, el, scope, true);
}

// Brush different type of elements.
Expand Down Expand Up @@ -785,7 +807,9 @@ function brushIncremental(
allClipped: false,
viewWidth: scope.viewWidth,
viewHeight: scope.viewHeight,
inHover: scope.inHover
inHover: scope.inHover,
darkMode: scope.darkMode,
darkColorMap: scope.darkColorMap
};
let i;
let len;
Expand Down
62 changes: 62 additions & 0 deletions src/tool/color.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import LRU from '../core/LRU';
import { Dictionary } from '../core/types';
import { extend, isGradientObject, isString, map } from '../core/util';
import { GradientObject } from '../graphic/Gradient';

Expand Down Expand Up @@ -593,3 +594,64 @@
// Change nothing.
return color;
}

/**
* text stroke is treated as 'stroke'
*/
export type ColorAttributeType = 'fill' | 'stroke' | 'textFill';

/**
* Convert color to dark mode.
* @param lightColor color in light mode
* @return color in dark mode, in rgba format
*/
export function convertToDark(
lightColor: string,
type: ColorAttributeType,
darkColorMap: Dictionary<string>
): string {
let colorArr = parse(lightColor);

if (colorArr) {
const colorStr = stringify(colorArr, 'rgba');
if (darkColorMap && darkColorMap[colorStr]) {
return darkColorMap[colorStr];
}

colorArr = rgba2hsla(colorArr);

switch (type) {
// TODO: Probably using other color space to enhance the result.
// Just a quick demo for now.
case 'stroke':
case 'textFill':
// Text color needs more contrast luminance?
colorArr[2] = 1 - colorArr[2];
break;
case 'fill':
default:
colorArr[2] = Math.min(1, (1 - colorArr[2]) * 1.1);
break;
}

// Desaturate a little.
colorArr[1] *= 0.9;

return stringify(hsla2rgba(colorArr), 'rgba');
}
}

export function normalizeColorMap(map: Dictionary<string>): Dictionary<string> {
if (!map) {
return {};
}

const normalizedMap: Dictionary<string> = {};
for (let key in map) {

Check failure on line 650 in src/tool/color.ts

View workflow job for this annotation

GitHub Actions / lint (18.x)

The body of a for-in should be wrapped in an if statement to filter unwanted properties from the prototype
const normalizedKey = stringify(parse(key), 'rgba');
if (normalizedKey) {
normalizedMap[normalizedKey] = stringify(parse(map[key]), 'rgba');
}
}
return normalizedMap;
}
19 changes: 17 additions & 2 deletions src/zrender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { GradientObject } from './graphic/Gradient';
import { PatternObject } from './graphic/Pattern';
import { EventCallback } from './core/Eventful';
import Displayable from './graphic/Displayable';
import { lum } from './tool/color';
import { lum, normalizeColorMap } from './tool/color';
import { DARK_MODE_THRESHOLD } from './config';
import Group from './graphic/Group';

Expand Down Expand Up @@ -83,6 +83,7 @@ class ZRender {
private _needsRefreshHover = true
private _disposed: boolean;
/**
* TODO: probably should be removed in the future
* If theme is dark mode. It will determine the color strategy for labels.
*/
private _darkMode = false;
Expand Down Expand Up @@ -117,7 +118,19 @@ class ZRender {
? false
: opts.useDirtyRect;

const painter = new painterCtors[rendererType](dom, storage, opts, id);
const isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to ensure the window exists. Add env.hasGlobalWindow before window.matchMedia and I recommend moving this line to 124 to avoid unnecessary invoking.

const darkMode = opts.darkMode === 'light'
? false
: (opts.darkMode === 'dark' ? true : isDark);

opts.darkColorMap = normalizeColorMap(opts.darkColorMap);
console.log(opts.darkColorMap)

const painter = new painterCtors[rendererType](dom, storage,
{
darkMode,
...opts
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use extend util instead of the extension operator.

}, id);
const ssrMode = opts.ssr || painter.ssrOnly;

this.storage = storage;
Expand Down Expand Up @@ -491,6 +504,8 @@ export interface ZRenderInitOpt {
devicePixelRatio?: number
width?: number | string // 10, 10px, 'auto'
height?: number | string
darkMode?: 'auto' | 'light' | 'dark'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semantically, the darkMode option sounds like a boolean value, do we need to support true/false rather than 'dark'/'light'?

Copy link
Member Author

@Ovilia Ovilia Aug 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was intentionly using 'light'/'dark' instead of boolean values because I think it may be ambiguous whether darkMode: true means using dark mode whatever the system dark mode is, or use dark mode only when the system dark mode is true. I'm open to what you think. And to avoid making this looks like a boolean attribute, I didn't use useDarkMode as useDirtyRect or useCoarsePointer.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was intentionly using 'light'/'dark' instead of boolean values because I think it may be ambiguous whether darkMode: true means using dark mode whatever the system dark mode is, or use dark mode only when the system dark mode is true.

It looks clear enough to me:

  • true - Always enable the dark mode.
  • false - Never enable the dark mode even if the system is.
  • 'auto' - follow the system preference.

This is how many apps do.

And if there is a must to avoid boolean value, 'colorScheme' (from CSS property color-scheme) may be a candidate for the name.

darkColorMap?: Dictionary<string>,
useDirtyRect?: boolean
useCoarsePointer?: 'auto' | boolean
pointerSize?: number
Expand Down
Loading