김문기

김문기님의 아티클

김문기

김문기

주니어(진) 개발자의 라이브러리 제작기

부제: 지도 UX 개선하기 (1.5) - 라이브러리 제작

image.png

지도 UX 개선하기 (1)에서 이어지는 글입니다!

지도의 특정 위치에서 잘 보이는 마커를 표시하기 위하는것을 목표로 하기 위해 다음의 목표를 산정했습니다.

  1. 좌표로부터 특정 확대 레벨에서의 타일을 가져옵니다

  2. 타일의 색상 분포를 추출합니다.

  3. 추출한 색상과 대비되는 색으로 마커의 색을 결정합니다.

그리고, 저번 아티클에서는 1번 단계를 구현하는 내용을 다루었습니다.

3번의 기능을 구현하기 위해서 타 라이브러리를 사용하고 있었지만,
사용중인 라이브러리의 미완성으로 인한 에러가 발생하였고,
이를 해결하기 위해 직접 라이브러리를 구현했습니다.

구현한 라이브러리는 아래의 라이브러리 링크 부분에서 확인할 수 있습니다.

이번 아티클에서는 3번의 기능을 구현하기 위해 제작한 라이브러리와 이를 구현하는 과정을 소개해보려 합니다.


방법 제시

이를 해결하기 위해 저는 아래의 방법을 제시했습니다.

  1. 원하는 기능을 제공하는 라이브러리 탐색 및 비교

  2. 개념 설명 및 문제 정의

  3. 라이브러리 개발

  4. 구현한 기능과 타 라이브러리 성능 비교


1. 라이브러리 탐색 및 비교

처음에는 이러한 기능을 제공하는 라이브러리가 이미 존재할 것이라고 생각했습니다.

이에 npm.js 웹사이트를 뒤적거리며 여러 라이브러리들이 어떠한 방법으로 색을 추출해내는지

코드를 직접 확인한 결과 필요한 기능을 제공해주는 라이브러리가 없는 것을 확인할 수 있었습니다.

아래는 제가 확인한 라이브러리들과, 이들을 사용할 수 없었던 이유입니다.

기능의 부재

colord

https://www.npmjs.com/package/colord

colore-js

https://www.npmjs.com/package/colore-js

위의 두 라이브러리는 색을 다루는 경우에 애용되는 라이브러리입니다. 다만, 두 라이브러리 모두 랜덤한 색상을 반환하는 기능은 제공하지만,

a11y 기준을 만족하는 색상을 추천하는 기능은 없었습니다.

기능의 불일치

react-color-a11y

https://www.npmjs.com/package/react-color-a11y

대비되는 색을 찾을 때 위에서 소개한 colord 라이브러리의 함수를 활용하여 목표 색을 밝게 / 어둡게 하는 방법을 통해 휘도를 조절하여 해결하는 라이브러리 입니다.

const shiftBrightnessUntilTargetLuminence = (originalColord: Colord, targetLuminence: TargetLuminence): Colord => {
  let newColord = originalColord

  const increment = 0.01

  if (targetLuminence.min !== undefined) {
    while (newColord.luminance() < targetLuminence.min && newColord.brightness() < 0.99) {
      newColord = newColord.lighten(increment)
    }
  } else if (targetLuminence.max !== undefined) {
    while (newColord.luminance() > targetLuminence.max && newColord.brightness() > 0.01) {
      newColord = newColord.darken(increment)
    }
  }

  return newColord
}

하지만, 이 라이브러리는 미리 전경색과 배경색을 지정해야 하므로, 필요한 기능과 맞지 않다고 판단했습니다.

기능의 미완성

accessible-colors

https://www.npmjs.com/package/accessible-colors

위 라이브러리는 발견 당시에는 필요한 함수 그 자체를 가지고 있어서 실제로 짧은 기간 사용했던 라이브러리 입니다.


하지만, 100%확률로 재현할 수 없는 에러가 자주 발생하였고, 이에 코드를 직접 확인한 결과

export const getRandomAAColor = (background: string, large = false): string => {
  let color = randomColor();
  while (!isAAContrast(background, color, large)) {
    color = randomColor();
  }
  return color;
};
  1. randomColor함수를 통해 랜덤한 색을 생성하고

  2. 해당 색이 Contrast값 이상을 가지는지 확인하여

  3. 대비값이 기준 이상인 경우 return 하는

방법을 사용하고 있었습니다.

하지만 이는 특정 색상이 없거나 (#808080과 같이 7.0 이상의 AAA 대조를 요구하는 경우 만족할 색상이 없는 등), 만족할 수 있는 색상 범위가 매우 좁은 경우 무한 루프에 빠지게 되어 라이브러리를 사용하는 개발자가 의도치 않은 오류를 겪게 될 가능성이 있습니다.

이에 라이브러리 관리자의 github에 issue를 통해 무한루프의 가능성에 대해 알려주었고, 아래와 같이 최대 시도 횟수를 정하여 최대 시도 횟수를 넘어가는 경우에는 null을 return 하는 경우로 코드를 수정하도록 했습니다.

export const getRandomAAColor = (
  background: string,
  large = false
): string | null => {
  let color = randomColor();
  let attempts = 0;
  while (!isAAContrast(background, color, large)) {
    if (attempts++ > 1000) {
      return null; // could not find a color that meets the contrast ratio within a reasonable number of tries
    }
    color = randomColor();
  }
  return color;
};

하지만, 완전히 random하게 뽑아낸 후 test하는 방법이 근본적인 해결이라고 생각되지 않아서 해당 라이브러리의 사용은 보류하는 것으로 결정했습니다.

비슷하지만, 원하지 않는 결과값

a11y-color

https://www.npmjs.com/package/a11y-color

라이브러리의 이름부터 심상치 않은, 근본 가득한 라이브러리같은 기대를 품고 코드를 열어보았는데

업데이트가 9년전 멈춘 코드에는 고대의 문법들이 가득했습니다.


라이브러리의 코드는 9년전 고등학교에서 잠만 자던 범부인 저에게는 너무나도 어려운 내용만 가득했고,
더욱이 절망적으로 github의 repository는 이미 지워진지 오래였습니다.

하지만, 이 라이브러리를 그대로 사용하기에는 라이브러리의 함수를 사용했을 때 결과값의 색상이 잘 보이긴 했지만, 비슷한 색 중, 잘 보이는 색이 반환되는 느낌을 지울 수 없었습니다.

이에 옮겨진 패키지의 레포지토리를 오랜 탐색 끝에 찾을 수 있었고, 해당 라이브러리에서 어느정도 업데이트 된 코드를 통해 대략의 이해를 할 수 있었습니다.

코드를 분석한 결과, 해당 라이브러리는 다음과 같은 방법을 통해 대비되는 색을 추출하고 있었습니다.

  1. 먼저, 전경색이나 배경색을 YCbCr 색 공간으로 변환합니다.
    YCbCr 색 공간은 색의 밝기(휘도)와 색상 성분(Cb, Cr)을 분리하여 표현할 수 있어, 색상을 다루기 편리합니다.

  2. 그다음, 목표로 하는 휘도에 맞추기 위해 YCbCr 색 공간에서 휘도 값을 조정합니다.
    이때 색상 성분(Cb, Cr)은 그대로 유지하면서 밝기만을 조절해, 원래 색상의 느낌을 최대한 보존합니다.

  3. 마지막으로, 조정된 YCbCr 색상을 다시 RGB 색 공간으로 변환하여 최종적으로 대비가 잘 맞는 새로운 색상을 생성합니다.
    이 과정에서 생성된 색상은 목표 대비 비율을 충족하도록 최적화된 결과입니다.

이 중 2번 단계를 조금 더 자세히 살펴보겠습니다.

let desiredFgLuminance = luminanceFromContrastRatio(bgLuminance, desiredContrast + 0.02, fgLuminanceIsHigher);

코드 중에 위와 같은 부분이 있는데 중간에 0.02를 더해주는 부분이 색상값은 변경을 많이 하지 않으면서 휘도를 유지하는 방법을 통해 자연스러운 색상을 찾기 위한 조정값을 더해주는 부분입니다.

즉, 해당 라이브러리에서 반환하는 색상 값은 휘도는 충분히 변하였지만, 색상은 비슷한 느낌을 주는 대비색을 찾아서 반환하는 라이브러리 인 것입니다.

필요한 기능은 자연스러운 색이 아닌, 눈에 잘 띄는 색이였기에 해당 라이브러리의 사용을 포기했습니다.

라이브러리 탐색 결론

여러 라이브러리를 분석한 결과, 지도의 특정 위치에서 잘 보이는 마커를 표시하기 위한 요구 사항을 충족하는 완벽한 솔루션이 없다는 결론에 이르렀습니다.

이에 직접 라이브러리를 개발해야겠다는 결론을 내렸습니다.


2. 문제 정의 및 개념 설명

문제 정의

두 색의 대비값은 어렵지 않게 계산을 통해 구해낼 수 있습니다. (이는 아래의 개념 설명과 계산 예시에서 자세히 다루겠습니다)

하지만, 한 색으로부터 대비되는 색을 추출해내는것은 어떻게 할 수 있을까요?

이에 아래와 같이 필요 기능 및 문제를 정의했습니다.

  • 색과 대비 비율이 주어질 때 이를 만족하는 색을 추출할 수 있다.

  • 실행 결과 기존의 무작위 추출 후 검증하는 라이브러리보다 높은 정확성을 보인다

  • 실행 결과 기존의 무작위 추출 후 검증하는 라이브러리보다 빠른 속도를 보인다

개념 설명

색 공간과 색상 대비

색(color)은 RGB(A), HSV, YCbCr, CMYK 등의 여러 약속된 방법을 통해 나타낼 수 있습니다.

이는 각각의 색 공간을 의미하고, 각 공간 사이에는 여러 방법을 통해 변환이 이루어질 수 있습니다. (같은 색을 나타내는 서로 다른 방법이 있는 것 이니까요)

각각의 색 공간은 다음의 특징을 가지고 있습니다.

색 공간

색 공간은 색상을 표현하는 방법을 정의하는 시스템으로, 여러 가지가 존재합니다. 각각의 색 공간은 특정한 용도와 목표를 염두에 두고 설계되었습니다.

  1. RGB (Red, Green, Blue):

    • 용도: 디지털 디스플레이에서 가장 널리 사용되는 색 공간입니다.

    • 특징: 각 색상을 빨간색, 녹색, 파란색의 혼합으로 나타내며, 이 세 가지 기본 색의 강도를 조절하여 다양한 색상을 생성합니다. 그러나, RGB 색 공간은 인간이 인지하는 색의 차이를 정확히 반영하지 못하는 한계가 있습니다.

  2. HSV (Hue, Saturation, Value):

    • 용도: 색상 선택 도구에서 자주 사용됩니다.

    • 특징: 색상(Hue), 채도(Saturation), 명도(Value)로 색을 나타냅니다. 이 색 공간은 색의 시각적 속성을 더 직관적으로 표현할 수 있어, 사용자 인터페이스 디자인에서 많이 활용됩니다.

  3. YCbCr:

    • 용도: 영상 압축 및 디지털 비디오에서 자주 사용됩니다.

    • 특징: 휘도(Y)와 색 차이(Cb, Cr) 성분으로 색을 나타냅니다. YCbCr 색 공간은 밝기와 색상을 분리하여 저장함으로써 데이터 압축에 유리한 특성을 가집니다.

  4. CMYK (Cyan, Magenta, Yellow, Key/Black):

    • 용도: 인쇄에서 주로 사용됩니다.

    • 특징: 인쇄에서 잉크의 네 가지 색을 혼합하여 색상을 생성합니다. CMYK는 감산 혼합 방식으로, 각 색상이 더해질수록 밝기가 줄어들어 더 어두운 색이 됩니다.

색상 대비

색상 대비는 두 색상이 서로 얼마나 구별될 수 있는지를 나타내는 지표입니다. 이 대비는 텍스트를 읽기 쉽게 만들거나 특정 요소를 눈에 잘 띄게 하는 데 필수적인 요소입니다. 색상 대비를 계산할 때는 주로 휘도 대비를 기준으로 합니다.

휘도 대비 (Luminance Contrast):

그림1

[그림1] - 휘도 대비 개념

휘도 대비는 두 색상의 휘도(Luminance) 차이를 기반으로 계산되며, 일반적으로 1:1(같은 휘도)에서 21:1(최대 대비)까지의 범위로 나타냅니다.

이 비율은 색상 대비를 정량적으로 표현하는 데 사용되며, 웹 콘텐츠 접근성 지침(WCAG, Web Content Accessibility Guidelines)에 따르면 텍스트와 배경 간의 최소 대비 비율은 다음과 같습니다:

  • 일반 텍스트: 최소 4.5:1 (AA 기준)

  • 큰 텍스트 또는 UI 컴포넌트: 최소 3:1 (AA 기준)

이 기준을 충족하는지 확인하려면 WCAG 대조 계산기를 사용할 수 있습니다.

휘도 (Luminance):

휘도는 색상의 밝기를 측정하는 값으로, 색상 대비를 계산하는 데 중요한 역할을 합니다. 휘도는 RGB 색상 값에 따라 계산되며, 각 색상의 가중치를 반영하여 특정 공식을 통해 계산됩니다. 이 공식은 인간의 시각이 특정 색상(예: 녹색)에 더 민감하게 반응한다는 점을 고려하여 만들어졌습니다. 휘도를 계산하는 일반적인 공식은 다음과 같습니다:

image.png

[그림2] - 휘도 개념

여기서 R, G, B는 각각 빨강, 녹색, 파랑의 색상 값(0에서 255 사이)를

  1. 정규화: [0,255]의 색상값을 [0,1]으로 변환

  2. 선형화: 가감마된 색상 공간의 값을 선형화

  3. 휘도 계산: L = 0.2126 * R + 0.7152 * B + 0.0722 * G를 사용하여 휘도 계산

의 단계를 거쳐서 휘도값을 계산합니다.

이때 사용된 가중치(0.2126, 0.7152, 0.0722)는 각각 빨간색, 초록색, 파란색에 대한 인간의 시각 민감도를 반영한 것입니다. 이 값들은 국제표준으로 정의된 sRGB 색 공간의 특성에 기반합니다.

여러 색 공간과 색상 대비를 할 때 사용하는 지표가 있지만, 저는 RGB와 휘도 대비를 사용하였습니다.

휘도 계산 예시

직접 계산을 해보면서 예시를 들어보겠습니다.

색 정의

계산에는 아래의 색을 사용합니다.

[그림3-1] - 색 정의

색 변환 (8bit → linear)

색의 휘도를 계산하기 위해 색의 각 RGB값을 linear한 값으로 변환해야합니다.

[그림3-2] - 색 변환

휘도 계산

각 색의 휘도를 계산합니다. 이를 위해 L = 0.2126 * R + 0.7152 * B + 0.0722 * G의 공식을 사용합니다.

image.png

[그림3-3] - 휘도 계산

휘도 대비 계산

휘도 대비를 계산합니다.

image.png

[그림3-4] - 휘도 대비 계산

결과 분석

https://webaim.org/resources/contrastchecker/?fcolor=B478FF&bcolor=0A0A0A

두 색은 훌륭한 6.67의 대비값을 보이고 있습니다.

3.0 이상의 대비값을 보이며 WCAG의 기준을 충족하며, 동시에 4.5 이상의 대비값으로 작은 글씨에서도 기준을 만족하고 있습니다.

[그림3-5] - 계산 결과 분석


3. 라이브러리 개발

문제 정의는 세줄이지만, 사실 해야할 일은 단 하나입니다

“색과 대비 비율이 주어질 때 이를 만족하는 색을 추출하는 라이브러리를 잘(정확하고 빠르게) 만든다”

변수 선언

색과 대비 비율이 주어질 때 라고 했으니 이를 파라미터로 입력받을 수 있도록 해야겠죠

이를 위해 RGB type을

export type RGB = [number, number, number];

와 같이 선언해주었고, 대비 정도는 multiplier변수로 사용했습니다.

휘도 구하기

getLuminance

함수가 시작하면 냅다 배경색의 휘도를 구해야합니다. 모든 계산은 휘도를 기반으로 사용되고,

휘도는 함수 내부에서 임의로 사용할 값이 아닌, 이미 정해져있는 개념이므로 get~~의 문법을 사용하여

해당 라이브러리를 import할때 활용할 수 있도록 네이밍했습니다.

export const normalizeElement = (element: number): number => {
  return element / 255;
};

export const linearizeElement = (element: number): number => {
  return element <= 0.03928
    ? element / 12.92
    : Math.pow((element + 0.055) / 1.055, 2.4);
};

export const RGB8bitToLinear = (channelValue: number): number => {
  if (channelValue < 0 || channelValue > 255) {
    throw new Error('Invalid 8-bit RGB channel value.');
  }
  // 🎨 [0,255]의 값을 [0,1] 정규화
  const normalizedValue = normalizeElement(channelValue);
  // 🎨 8bit RGB를 Linear하게 선형화
  const linearizedValue = linearizeElement(normalizedValue);

  return linearizedValue;
};

export const getLuminance = (RGBValue: RGB): number => {
  const LinearRGB = RGBValue.map(RGB8bitToLinear); 
	// 🎨 가중치를 곱하여 휘도 계산
  return 0.2126 * LinearRGB[0] + 0.7152 * LinearRGB[1] + 0.0722 * LinearRGB[2];
};

위의 함수를 사용하면 input으로 주어지는 배경색의 휘도를 계산할 수 있습니다.

전경색의 휘도 범위 구하기

calculateBounds

우리는 대비 비율이 multiplier 이상인 값을 찾아야 합니다.

image.png

RGB 색 공간에서는 색이 섞일수록 밝아지기 때문에, 밝은 색의 휘도는 어두운 색의 휘도보다 항상 큰 값을 가집니다. 즉 위의 수식은 아래와 같습니다.

image.png

배경색의 휘도 값을 k라고 하면, 배경이 더 밝은 경우와 어두운 경우에 대해 아래와 같은 수식을 도출할 수 있습니다.

i) 배경이 밝은 경우

image.png

ii) 배경이 어두운 경우

image.png

즉, 배경이 밝은 경우와 어두운 경우 각각 upperbound와 lowerbound를 두고 양 끝값의 범위에서 휘도를 구할 수 있음을 의미합니다.

image.png

[그림4-1] - 휘도 bounds 그래프

위의 내용을 함수로 구현하면 다음과 같습니다.

const calculateBounds = (
  multiplier: number,
  luminance: number
): [number, number] => {
  const lowerBound = luminance / multiplier + 0.05 * (1 / multiplier - 1);
  const upperBound = multiplier * luminance + 0.05 * (multiplier - 1);

  return [Math.max(0, lowerBound), Math.min(1, upperBound)];
};

휘도 범위를 구하는 함수의 경우는 해당 라이브러리에서만 사용할 개념입니다.


그렇기 때문에 get~의 네이밍을 사용하지 않고, caculate~의 네이밍을 통해 내부 로직임을 표현했습니다.


(모듈화된 타 함수들의 경우에도 get~의 네이밍 컨벤션의 함수만이 라이브러리를 import했을 때 사용할 수 있는 함수입니다)

배경색의 휘도 고르기

calculateTotalLength, generateTargetLuminance

[그림 4-1]의 그래프에서 보라색 영역에 해당하는 휘도를 선택하는 방법에 대해 고민해 보았습니다.

  1. 휘도 차이가 딱 multiplier만큼 나는 부분을 고르면 되지 않나?

    이 방법은 직관적으로 매력적입니다. 하지만, 대비 효과가 딱 multiplier여야만 하는 특별한 이유가 없었습니다.
    오히려, 가능한 한 대비 효과를 (적당한 범위 내에서) 극대화 하는 것이 바람직한 상황이었기 때문에,
    최소한의 multiplier이상의 휘도 값을 보장하는 값을 선택하기로 했습니다.

  2. 그러면 휘도 차이가 최대한 많이 나는 부분을 고르면 되지 않을까?

    이 선택지는 곧바로 폐기했습니다. 이유는 명확합니다.

    휘도 차이가 가장 큰 값은 검은색([0,0,0])이거나, 흰색([255,255,255]) 둘 중 하나의 색임에 분명했죠.

    이 둘 중 하나를 반환하는 것은 색상 대비를 뚜렷하게 만들기는 하지만, 애초에 이런 간단한 방법을 사용할 것이었다면, 애초에 라이브러리를 만들 생각도 하지 않았을 것 입니다.

  3. 그럼 남은 결론은 단 하나, random한 휘도 값!

    최종적으로, [그림 4-1]의 보라색 영역 안에서 무작위(random)로 휘도 값을 선택하는 것이 가장 적절하다고 결론지었습니다.

image.png

[그림4-2] - 영역에서 무작위 휘도 선택하기

범위 내에서 무작위한 값을 선택하기 위해 Math.rand() 매서드를 사용했습니다. 하지만, rand() 매서드는 그림 4-2의 (1)처럼 [0,1)의 결과값을 return합니다

우리는 (2) 에 해당하는 calculateBounds()함수로부터 추출한 보라색 영역안의 임의의 영역을 선택해야하기 때문에 (3)과 같이 범위를 모아주고, 모은 전체 범위의 길이를 return하는 calculateTotalLength함수를 제작했습니다.

그러면 우리는 [0,1)의 값을 return하는 rand()에 calculateTotalLength()를 곱해주면 (4) 보라색 범위 내의 영역에서 random한 값을 선택할 수 있게 됩니다.

해당 부분의 코드는 아래와 같습니다.

const calculateTotalLength = (bounds: number[]):number => {
  const lowerBound = bounds[0];
  const upperBound = bounds[1];

  return lowerBound + 1 - upperBound;
};

무작위 영역은 위의 함수를 통해서 고를 수 있지만, 고른 부분은 (4)와 같이 빨간색의 영역으로 연속된 공간에 있습니다.


하지만, 실제 선택해야하는 휘도의 boundary는 (2)의 보라색 영역과 같이 불연속하게 위치해있습니다.

이를 위해 삼항연산자를 사용하여 lowerBound와 (4)에서 추출한 임의의 값과 비교하여

lowerBound보다 작은 경우에는 해당 값을 그대로 반환하고,

lowerBound보다 큰 경우에는 값을 보라색 우측 영역으로 이동시켜주는 로직을 추가했습니다.

이는 아래와 같은 코드를 사용하여 구현했습니다.

const generateTargetLuminance = (
  totalLength: number,
  lowerBound: number,
  upperBound: number,
  weight: number
): number => {
  const _tmp = Math.random() * totalLength * weight;
  return _tmp > lowerBound ? _tmp + (-lowerBound + upperBound) * weight : _tmp;
};

여기서 weight를 곱해주는 이유는 각 RGB 채널의 가중치를 반영하기 위해서입니다. 만약 weight를 사용하지 않으면,
각 채널에서 선택된 휘도 값이 전체적으로 일관된 색상 범위를 형성하지 못할 수 있습니다.

이는 각 채널의 기여도가 다르기 때문에, 각 채널에서 선택된 값이 균등한 확률로 선택되면, 시각적으로 부자연스럽거나 잘못된 색상 조합이 생성될 수 있습니다.

하지만, weight를 곱해줌으로써 각 채널의 휘도 값이 올바르게 가중된 상태에서 선택되며, 이를 통해 인간의 시각적 경험이 반영된 색상 범위를 선택할 수 있었습니다.

대비 색상 찾기

calculateContrastColorInnerLogic, getContrastColor

드디어 모든 모듈 함수들의 제작이 끝났습니다. 이제 전체적인 함수가 모듈화된 함수들을 순서에 맞게 합쳐주기만 하면 되겠네요!

calculateContrastColorInnerLogic

이 함수는 주어진 RGB 색상(color)과 목표 휘도(luminance)에 대해, 해당 색상보다 더 큰 대비를 갖는 색상을 찾는 내부 로직을 담당합니다.

구체적으로 이 함수는 다음과 같은 단계를 거칩니다:

  1. 기본 색상의 휘도 계산: 먼저, getLuminance 함수를 통해 입력된 색상(color)의 배경 휘도(backgroundLuminance)를 계산합니다.
    이 값은 색상 대비를 계산하는 기초가 됩니다.

  2. 휘도 범위 계산: calculateBounds 함수를 사용하여 목표 휘도(luminance)와 배경 휘도(backgroundLuminance)를 바탕으로
    휘도 범위([lowerBound, upperBound])를 계산합니다. 이는 해당 색상이 가질 수 있는 휘도의 하한과 상한을 의미합니다.

  3. 전체 범위 길이 계산: calculateTotalLength 함수를 사용해 계산된 휘도 범위 내에서 사용할 수 있는 전체 길이를 계산합니다.
    이 길이는 무작위로 휘도 값을 선택할 때 기준이 됩니다.

  4. 채널별 무작위 휘도 생성: generateTargetLuminance 함수는 무작위한 휘도 값을 생성합니다.
    각 RGB 채널(빨강, 녹색, 파랑)마다 이 값을 개별적으로 계산하여, 각 채널의 가중치(weight)를 반영한 무작위 휘도 값을 얻습니다.

  5. 색상 조합: 각 채널에서 선택된 무작위 휘도 값을 바탕으로 선형 RGB 값을 계산하고(LinearRGBfromLuminance),
    이를 다시 8비트 RGB 값으로 변환하여 최종 색상을 구성합니다(RGBLinearTo8bit).

이 과정은 각 RGB 채널의 휘도 값이 자연스럽고 일관된 색상 범위를 형성하도록 하며, 최종적으로 주어진 목표 휘도보다 더 큰 대비를 가지는 색상을 생성합니다.

해당 함수의 코드는 아래와 같이 작성했습니다.

export const calculateContrastColorInnerLogic = (
  color: RGB,
  luminance: number
): RGB | null => {
  // 🎨 기본 색상의 휘도 계산
  let backgroundLuminance = getLuminance(color);
  // 🎨 휘도 범위 계산 (Red 채널)
  let [lowerBound, upperBound] = calculateBounds(
    luminance,
    backgroundLuminance
  );
  // 🎨 전체 범위 길이 계산 (Red 채널)
  let totalLength = calculateTotalLength([lowerBound, upperBound]);

  // 🎨 채널별 무작위 휘도 생성 (Red 채널)
  let _targetLuminance = generateTargetLuminance(
    totalLength,
    lowerBound,
    upperBound,
    RGBChannelWeights.R
  );

  const LinearR = LinearRGBfromLuminance(_targetLuminance, 'R');
  const _8bitR = RGBLinearTo8bit(LinearR);

  backgroundLuminance -= _targetLuminance;
  // 🎨 휘도 범위 계산 (Green 채널)
  [lowerBound, upperBound] = calculateBounds(luminance, backgroundLuminance);
  // 🎨 전체 범위 길이 계산 (Green 채널)
  totalLength = calculateTotalLength([lowerBound, upperBound]);

  // 🎨 채널별 무작위 휘도 생성 (Green 채널)
  _targetLuminance = generateTargetLuminance(
    totalLength,
    lowerBound,
    upperBound,
    RGBChannelWeights.G
  );

  const LinearG = LinearRGBfromLuminance(_targetLuminance, 'G');
  const _8bitG = RGBLinearTo8bit(LinearG);

  backgroundLuminance -= _targetLuminance;
  // 🎨 휘도 범위 계산 (Blue 채널)
  [lowerBound, upperBound] = calculateBounds(luminance, backgroundLuminance);
  // 🎨 전체 범위 길이 계산 (Blue 채널)
  totalLength = calculateTotalLength([lowerBound, upperBound]);

  // 🎨 채널별 무작위 휘도 생성 (Blue 채널)
  _targetLuminance = generateTargetLuminance(
    totalLength,
    lowerBound,
    upperBound,
    RGBChannelWeights.B
  );

  const LinearB = LinearRGBfromLuminance(_targetLuminance, 'B');
  const _8bitB = RGBLinearTo8bit(LinearB);

  // 🎨 색상 조합
  return [_8bitR, _8bitG, _8bitB];
};

getContrastColor

이 함수는 calculateContrastColorInnerLogic을 활용하여 주어진 색상(color)과 목표 휘도(luminance)에 대해, 그보다 높은 대비를 가지는 색상을 찾습니다.

외부에서 호출되는 인터페이스로, 사용자에게 최종 대비 색상을 제공하는 역할을 합니다.

구체적인 작동 방식은 다음과 같습니다:

  1. 입력 값 검증: validateColor와 validateLuminance 함수를 사용해 입력된 RGB 색상 배열과 휘도 값이 유효한지 확인합니다.
    잘못된 값이 들어오면 예외를 발생시킵니다.

  2. 반복적인 색상 탐색: 내부적으로 1000번의 반복을 통해 calculateContrastColorInnerLogic을 호출하여 무작위로 색상을 생성하고,
    그 색상이 목표 휘도보다 높은 대비를 가지는지 확인합니다. 만약 조건을 충족하는 색상이 발견되면, 해당 색상을 반환합니다.

  3. 예외 처리: 만약 calculateContrastColorInnerLogic 함수에서 발생하는 예외가 ”Invalid linear RGB value”일 경우, 그 색상은 무시하고 다음 반복으로 넘어갑니다. 다른 예외가 발생하면 해당 예외를 다시 던집니다.

  4. 적합한 색상 찾기 실패: 만약 1000번의 시도 내에 조건을 충족하는 색상을 찾지 못하면, null을 반환하여 적절한 색상을 찾지 못했음을 알립니다.

이 함수는 사용자에게 명확한 목표 대비를 달성할 수 있는 색상을 제공하며, 만약 목표를 달성하지 못할 경우에도 안전하게 처리되도록 설계되어 있습니다.

해당 로직은 아래와 같은 코드로 작성했습니다.

export const getContrastColor = (
  color: number[],
  luminance: number
): RGB | null => {
  // 🎨 입력 값 검증
  validateColor(color as RGB);
  validateLuminance(luminance);
  const bgColor = color as RGB;

  let i = 0;
  while (i < 1000) {
    i++;
    try {
      // 🎨 반복적인 색상 탐색
      const targetColor = calculateContrastColorInnerLogic(bgColor, luminance);
      if (getContrastRatio(targetColor!, bgColor) > luminance) {
        return targetColor;
      }
    } catch (e: any) {
      // 🎨 예외 처리
      if (e.message === 'Invalid linear RGB value') {
        continue;
      } else {
        throw e;
      }
    }
  }
  // 🎨 적합한 색상 찾기 실패
  return null;
};


4. 성능 비교

아래는 성능 비교를 위해 같은 기능을 제공하는 타 라이브러리를 import 하여 테스트 코드를 작성하여 30회정도 테스트 한 평균을 나타낸 테이블입니다.

image.png

[그림 5-1] - 성능 측정중 k회차의 캡처본

image.png

라이브러리 링크

위의 개발 과정을 통해 구현한 코드는 빌드하여 오픈소스로 깃허브에 공개해두었고, 패키지는 배포하여 업로드하였습니다.

데모 링크 : https://a11y-contrast-color.vercel.app/

라이브러리 링크 : https://www.npmjs.com/package/a11y-contrast-color

깃허브 레포지토리 : https://github.com/moong23/a11y-contrast-color

결론

이번 성능 비교를 통해 직접 개발한 라이브러리가 외부 라이브러리와 비교했을 때 평균적으로 더 빠르게 동작한다는 것을 확인할 수 있었습니다.


특히, 특정 상황에서는 외부 라이브러리와 동일한 iteration을 하는 조건에서도 열 배 이상 빠른 성능을 보이기도 했습니다.
이러한 성능 향상은 사용자 경험을 향상시키고, 지도에서 마커를 보다 명확하게 표시하는 데 중요한 역할을 할 수 있을것으로 기대가 됩니다.

따라서, 이번 프로젝트에서 제시한 목적에 맞게 지도의 특정 위치에서 잘 보이는 마커를 표시하기 위해 자체 개발한 라이브러리를 사용하는 것이 더욱 유리하다고 판단됩니다.


해당 라이브러리는 오픈소스 프로젝트로, 지속적인 관리를 통해 색상을 추출하는 알고리즘을 유지보수하도록 하고, 더 나은 알고리즘이 있다면 교체하여 최선의 색상을 선택하는데 도움이 되도록 할 예정입니다.

이로써, 이번 아티클에서 다루고자 했던 내용들을 모두 마무리하며, 다음 단계에서는 다시 원래의 아티클로 돌아가서 타일으로부터 색상 분포를 추출하고, 추출한 색으로부터 해당 라이브러리를 사용하여 눈에 잘 보이는 마커를 표시하는 과정에 대한 아티클을 이어가겠습니다.

감사합니다!

참고자료

https://www.w3.org/TR/WCAG20/relative-luminance.xml
https://www.w3.org/TR/WCAG20/#contrast-ratiodef
https://www.w3.org/WAI/older-users/developing/#color
https://www.npmjs.com/package/colord
https://www.npmjs.com/package/colore-js
https://www.npmjs.com/package/react-color-a11y
https://www.npmjs.com/package/accessible-colors
https://www.npmjs.com/package/a11y-color

4
4
김문기

김문기

지도 UX 개선하기 (1)

💡 이 글에서는 아래의 기술을 사용합니다!
지도 : Mapbox - Deck.gl과 호환이 좋은 지도 SDK
레이어 : Deck.gl - WebGL2를 사용하여 대규모 데이터 시각화에 최적화된 라이브러리
타일 : OpenStreetMap(OSM)

이 글을 통해 다음의 내용을 알 수 있습니다!

  • 좌표로부터 렌더링할 지도의 타일을 불러올 수 있습니다.

  • 지도의 타일으로부터 색상을 추출할 수 있습니다.

  • 추출한 색상으로부터 대비되는 색상을 추출할 수 있습니다.

현재 진행중인 프로젝트에서 공간 데이터를 지도 위에 표시하는데, 여러 타입의 지도에서도 잘 보이는 색으로 표시해야하는 요구사항이 있어서, 이를 해결하기 위해 고민한 내용과 해결방법을 공유하고자 글을 작성했습니다!

문제 정의

다음은 Mapbox에서 기본적으로 제공해주는 한국 지도 데이터와 지도 타일을 사용한 지도입니다.


하지만, 이는 15레벨 이상으로 확대하면 해당 아티클에서는 불필요한 빌딩 데이터가 렌더링되고, 지도의 정확도 또한 OSM이나 타 한국의 지도보다 떨어지는 편이니 Mapbox의 레이어와 호환되는 부분만을 사용하도록 하고, Mapbox에서 기본적으로 제공되는 tile과 building 데이터는 사용하지 않도록 하겠습니다.

image.png

먼저, Mapbox Studio에서 기본 타일을 grayscale하고, 빌딩 데이터와 같은 불필요한 정보들을 삭제한 후 로드했습니다.

image.png

하지만, 앞서 기술했듯 Mapbox의 타일은 정확도가 떨어지는 편이니 비교적 정확도가 높은 OSM의 타일을 사용해보겠습니다.

image.png

간단하게 좌표를 지정하여 하늘색 마커를 띄워보겠습니다.

image.png

위와 같이 zoom이 낮은 부분에서는 마커가 비교적 잘 보이는 편입니다.

image.png

하지만, 이를 확대하면 아래와 같이 오른쪽 하천 부분의 마커가 보이지 않습니다.

(물론 해당 마커는 극적인 효과를 위해 강의 색상과 동일한 색상을 사용했기 때문에 잘 안보이는 경우가 맞습니다.)

하지만, 아래와 같은 상황을 고려해야합니다.

  1. OSM의 타일이 아닌 다른 형식의 타일을 사용할 수 있음.

  2. 위성지도타일과 같은 아예 색감이 다른 타일을 사용할 수 있음.

정리하자면,

  1. 마커는 위치가 정해져있지 않고, 변경될 수 있다

  2. 마커의 위치가 변함에 따라 마커 아래 타일의 색상 분포가 달라진다

  3. 타일의 종류가 다양할 수 있다.

의 문제점이 있다고 할 수 있습니다.

트러블 슈팅

방법 제시

이를 해결하기 위해 저는 다음과 같은 방법을 제시했습니다.

(문제점을 해결하기 위해 시도한 방법중 하나를 공유하는 것일 뿐 정답이 아닐 수 있습니다!)

  1. 좌표로부터 특정 확대 레벨에서의 타일을 가져옵니다

  2. 타일의 색상 분포를 추출합니다.

  3. 추출한 색상과 대비되는 색으로 마커의 색을 결정합니다.

해당 글에서는 1의 과정을 풀어내고, 2와 3의 과정은 다음 글에서 이어서 작성하겠습니다.

“좌표로부터 특정 확대 레벨에서의 타일을 가져옵니다.”

해당 작업은 비교적(?) 어렵지 않게 작업할 수 있었습니다.

Open Street Map Wiki의 Slippy map tilenames문서에서 자세히 설명된 과정을 따라가며 어렵지 않게 구현하 수 있었습니다….만… 중간에 위키의 잘못된(이라 생각되는) 부분이 있어서 그렇게 생각하는 이유와 구현 과정을 풀어보겠습니다.

+) 해당 문서를 공부하면서 한글 문서로도 번역을 해놓았으니, 한글문서로 읽으셔도 무방합니다.

개요

미끄러운 지도(Slippy Map : Static Map은 움직이지 않는 지도로, 이와 반대되는 지도)에서는 타일에 대해 다음과 같이 정의합니다.

  • 타일은 256 × 256 픽셀 PNG 파일입니다.

  • 각 확대 레벨은 디렉터리이고, 각 열은 하위 디렉터리이며, 그 열의 각 타일은 파일입니다.

  • 파일 이름(또는 URL) 형식은 /zoom/x/y.png입니다.

첫번째 줄은 명백하니 넘어가고,

두번째 줄은 우리가 시스템에서 파일 경로를 /User/moonki/Desktop/... 과 같이 작성할 수 있듯이, 해당 타일이 위치한 경로를 zoom - x - y 의 구조로 저장하고 있다는 것을 알 수 있습니다.

또한 이 구조는 파일경로를 검색할때와 동일하게 /zoom/x/y.png으로 접근할 수 있습니다.

이때 쓰이는 x, y의 좌표를 구하기 위해 문서의 내용을 따라가보겠습니다.

타일 서버

타일은 지도 서비스를 제공하는 여러 업체에서 url의 형식으로 에셋을 접근할 수 있습니다.

여러 제공업체에서 이를 제공하고 있지만, 저작권의 이유로 사용하지 못하는 경우도 있으니 알아보고 사용하셔야 합니다.

image.png

이를 위해 저는 OSM의 tile을 사용하도록 하겠습니다.

🗺️ ~://tile.openstreetmap.org/zoom/x/y.png

확대 레벨

확대 레벨은 해당 아티클에서는 16의 확대 레벨으로 두고 사용하겠습니다

🗺️ ~://tile.openstreetmap.org/16/x/y.png

타일 이름 유도하기

해당 좌표 타일의 위치 x, y값을 가져오기 위해 현재 좌표로부터 타일의 위치를 유도하는 과정을 유도해보겠습니다.

예시를 위해 아래의 두 좌표를 담은 mockData를 사용하겠습니다.

mockData = [
	{
	  id: 0,
	  position: [127.10501086, 37.35887931],
	},
	{
	  id: 1,
	  position: [127.10881588, 37.3613688],
	},
]

데이터는 위도-경도 좌표계로 알려져있는 EPSG:4326(WGS 84)의 형식을 따르고 있습니다.

이 중 첫번째 position을 위키의 내용을 따라서 EPSG:3857의 형식으로 변환해보겠습니다.

image.png

위키의 예시에서는 EPSG:3857의 format으로 변환한 데이터가

x: lat을 그대로 따름
y: (-π, π)의 범위로 transform

이 되어야 하지만, 실제 EPSG translator를 사용하여 변환한 결과 x, y의 값이 터무니 없이 커져버렸습니다.

이해가 되지 않는 부분이 있어 OSM 커뮤니티에 질문을 남긴 결과 다음과 같은 대답을 얻을 수 있었습니다

요약하자면, 실제 EPSG:3857으로 표시되어있기는 하지만, 실제 값을 사용하지 않고 EPSG:3857을 변환할때 사용하는 방법을 차용한 것이므로, 이름을 EPSG:3857으로 정한것에는 문제가 있다(라고 생각한다) 라고 합니다.

이에 EPSG:3857을 따르지 않고, 수학 공식을 직접 변환하여 다음과 같은 식을 도출해낼 수 있습니다.

const tileX = Math.floor((numTile * (targetCoordX + 180)) / 360);
const tileY = Math.floor((numTile / 2) * (1 - Math.log(
        Math.tan((Math.PI / 180) * targetCoordY) +
          1 / Math.cos((Math.PI / 180) * targetCoordY)
      ) / Math.PI)
);

조금은 식이 복잡하지만, tileX와 tileY를 도출해내는 수학 공식을 각각 한줄의 코드로 간단하게 나타낸 부분입니다.

위의 코드를 사용하면 각각의 좌표에 대해서 다음과 같은 결과를 얻을 수 있습니다.

→ 좌표 1

before : [127.10501086, 37.35887931]
after : [55906, 25426]

image.pnghttps://tile.openstreetmap.org/16/55906/25426.png

→ 좌표 2

before : [127.10881588, 37.3613688]
after : [55907, 25425]

image.pnghttps://tile.openstreetmap.org/16/55907/25425.png

(급) 마무리 + 다음 아티클 예고

이렇게 위키의 내용을 따라 위도-경도 좌표계에서 지도 타일에서 쓰는 좌표계로의 변환 이후 타일의 위치를 알아내는 방법에 대해 작성해보았습니다.

이후의 이어지는 아티클에서는 위에서 얻은 지도 사진에서 k-means clustering을 통해 유효한 대표 색상값을 추출해내고, 해당 색상값들의 조합에서 잘 분리되어 보이는 색상을 골라내는 방법에 대해서 다루어보도록 하겠습니다

참고자료


https://developers.naver.com/forum/posts/27554
https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
https://wiki.openstreetmap.org/wiki/Ko:Slippy_map_tilenames
https://community.openstreetmap.org/t/clarification-needed-on-epsg-3857-coordinate-conversion-in-slippy-map-wiki/116242

3
3