Skip to content

Commit

Permalink
feat: use the dominant color of a user's avatar as the color of his/h…
Browse files Browse the repository at this point in the history
…er racing bar (#727)

* chore: github-url-detection v8 requires node >= 18

* feat: use the dominant color of a user's avatar as the color of his/her racing bar

* chore: an insteresting way of using parttern as color

* style: linear color is better

* chore: adjust grid left

* chore: smaller pics also work

* refactor: store colorCache in chrome.storage.local

* feat: add loading animation and fix storage writing racing
  • Loading branch information
tyn1998 authored Sep 22, 2023
1 parent b907fdc commit 746f7da
Show file tree
Hide file tree
Showing 8 changed files with 1,219 additions and 67 deletions.
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
18
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ OSS-GPT is an open source project document answering robot integrated with [Docs
Please read [CONTRIBUTING](./CONTRIBUTING.md) if you are new here or not familiar with the basic rules of Git/GitHub world.
### Requirements

1. node >= 16.14
1. node >= 18

2. yarn

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
"@hot-loader/react-dom": "^17.0.2",
"@types/chrome": "^0.0.203",
"@uifabric/icons": "^7.6.2",
"antd": "^5.9.1",
"buffer": "^6.0.3",
"colorthief": "^2.4.0",
"delay": "^5.0.0",
"dom-loaded": "^3.0.0",
"echarts": "^5.3.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// @ts-ignore - TS7016: Could not find a declaration file for module 'colorthief'.
import ColorThief from 'colorthief';

type LoginId = string;
type Color = string;
type RGB = [number, number, number];
interface ColorCache {
colors: Color[];
lastUpdated: number; // timestamp
}

/** The number determines how many colors are extracted from the image */
const COLOR_COUNT = 2;
/** The number determines how many pixels are skipped before the next one is sampled. */
const COLOR_QUALITY = 1;
/** The number determines how long the cache is valid. */
const CACHE_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;

/**
* A singleton class that stores the avatar colors of users.
*/
class AvatarColorStore {
private static instance: AvatarColorStore;
private colorThief = new ColorThief();

private loadAvatar(loginId: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = `https://avatars.githubusercontent.com/${loginId}?s=8&v=4`;
});
}

public async getColors(loginId: LoginId): Promise<Color[]> {
// Create a unique key for this user's cache entry.
const cacheKey = `color-cache:${loginId}`;

const now = new Date().getTime();
const colorCache: ColorCache = (await chrome.storage.local.get(cacheKey))[
cacheKey
];

// Check if the cache is stale or doesn't exist.
if (!colorCache || now - colorCache.lastUpdated > CACHE_EXPIRE_TIME) {
let colors: Color[];
// a single white color causes error: https://github.com/lokesh/color-thief/issues/40#issuecomment-802424484
try {
colors = await this.loadAvatar(loginId)
.then((img) =>
this.colorThief.getPalette(img, COLOR_COUNT, COLOR_QUALITY)
)
.then((rgbs) => {
return rgbs.map(
(rgb: RGB) => `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`
);
});
} catch (error) {
console.error(
`Cannot extract colors of the avatar of ${loginId}, error info: `,
error
);
colors = Array(COLOR_COUNT).fill('rgb(255, 255, 255)');
}

// Store the updated cache entry with the unique key.
await chrome.storage.local.set({
[cacheKey]: {
colors,
lastUpdated: now,
},
});

return colors;
}

// Return the cached colors.
return colorCache.colors;
}

public static getInstance(): AvatarColorStore {
if (!AvatarColorStore.instance) {
AvatarColorStore.instance = new AvatarColorStore();
}
return AvatarColorStore.instance;
}
}

export const avatarColorStore = AvatarColorStore.getInstance();
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
import React, { useEffect, useRef } from 'react';
import { RepoActivityDetails } from '.';
import { avatarColorStore } from './AvatarColorStore';

import React, { useEffect, useState, useRef } from 'react';
import * as echarts from 'echarts';
import type { EChartsOption, EChartsType } from 'echarts';
import type { EChartsOption, EChartsType, BarSeriesOption } from 'echarts';
import { Spin } from 'antd';

interface RacingBarProps {
repoName: string;
data: any;
data: RepoActivityDetails;
}

// TODO generate color from user avatar
const colorMap = new Map();

const updateFrequency = 3000;

const option: EChartsOption = {
grid: {
top: 10,
bottom: 30,
left: 150,
left: 160,
right: 50,
},
xAxis: {
Expand Down Expand Up @@ -45,19 +46,6 @@ const option: EChartsOption = {
realtimeSort: true,
seriesLayoutBy: 'column',
type: 'bar',
itemStyle: {
color: function (params: any) {
const githubId = params.value[0];
if (colorMap.has(githubId)) {
return colorMap.get(githubId);
} else {
const randomColor =
'#' + Math.floor(Math.random() * 16777215).toString(16);
colorMap.set(githubId, randomColor);
return randomColor;
}
},
},
data: undefined,
encode: {
x: 1,
Expand Down Expand Up @@ -94,21 +82,51 @@ const option: EChartsOption = {
},
};

const updateMonth = (instance: EChartsType, data: any, month: string) => {
const updateMonth = async (
instance: EChartsType,
data: RepoActivityDetails,
month: string
) => {
const rich: any = {};
data[month].forEach((item: any[]) => {
// rich name cannot contain special characters such as '-'
rich[`avatar${item[0].replaceAll('-', '')}`] = {
backgroundColor: {
image: `https://avatars.githubusercontent.com/${item[0]}?s=48&v=4`,
},
height: 20,
};
});
const barData: BarSeriesOption['data'] = await Promise.all(
data[month].map(async (item) => {
// rich name cannot contain special characters such as '-'
rich[`avatar${item[0].replaceAll('-', '')}`] = {
backgroundColor: {
image: `https://avatars.githubusercontent.com/${item[0]}?s=48&v=4`,
},
height: 20,
};
const avatarColors = await avatarColorStore.getColors(item[0]);
return {
value: item,
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 1,
y2: 0,
colorStops: [
{
offset: 0,
color: avatarColors[0],
},
{
offset: 0.5,
color: avatarColors[1],
},
],
global: false,
},
},
};
})
);
// @ts-ignore
option.yAxis.axisLabel.rich = rich;
// @ts-ignore
option.series[0].data = data[month];
option.series[0].data = barData;
// @ts-ignore
option.graphic.elements[0].style.text = month;

Expand All @@ -122,12 +140,12 @@ const updateMonth = (instance: EChartsType, data: any, month: string) => {

let timer: NodeJS.Timeout;

const play = (instance: EChartsType, data: any) => {
const play = (instance: EChartsType, data: RepoActivityDetails) => {
const months = Object.keys(data);
let i = 0;

const playNext = () => {
updateMonth(instance, data, months[i]);
const playNext = async () => {
await updateMonth(instance, data, months[i]);
i++;
if (i < months.length) {
timer = setTimeout(playNext, updateFrequency);
Expand All @@ -139,11 +157,14 @@ const play = (instance: EChartsType, data: any) => {

/**
* Count the number of unique contributors in the data
* @returns [number of long term contributors, contributors' names]
*/
const countLongTermContributors = (data: any) => {
const countLongTermContributors = (
data: RepoActivityDetails
): [number, string[]] => {
const contributors = new Map<string, number>();
Object.keys(data).forEach((month) => {
data[month].forEach((item: any[]) => {
data[month].forEach((item) => {
if (contributors.has(item[0])) {
contributors.set(item[0], contributors.get(item[0])! + 1);
} else {
Expand All @@ -158,42 +179,59 @@ const countLongTermContributors = (data: any) => {
count++;
}
});
return count;
return [count, [...contributors.keys()]];
};

const RacingBar = ({ data }: RacingBarProps): JSX.Element => {
const [loadedAvatars, setLoadedAvatars] = useState(0);
const divEL = useRef<HTMLDivElement>(null);

let height = 300;
const longTermContributorsCount = countLongTermContributors(data);
const [longTermContributorsCount, contributors] =
countLongTermContributors(data);
if (longTermContributorsCount >= 20) {
// @ts-ignore
option.yAxis.max = 20;
height = 600;
}

useEffect(() => {
if (!divEL.current) return;

const chartDOM = divEL.current;
const instance = echarts.init(chartDOM);

play(instance, data);

return () => {
if (!instance.isDisposed()) {
instance.dispose();
}
// clear timer if user replay the chart before it finishes
if (timer) {
clearTimeout(timer);
}
};
(async () => {
if (!divEL.current) return;

const chartDOM = divEL.current;
const instance = echarts.init(chartDOM);

// load avatars and extract colors before playing the chart
const promises = contributors.map(async (contributor) => {
await avatarColorStore.getColors(contributor);
setLoadedAvatars((loadedAvatars) => loadedAvatars + 1);
});
await Promise.all(promises);

play(instance, data);

return () => {
if (!instance.isDisposed()) {
instance.dispose();
}
// clear timer if user replay the chart before it finishes
if (timer) {
clearTimeout(timer);
}
};
})();
}, []);

return (
<div className="hypertrons-crx-border">
<div ref={divEL} style={{ width: '100%', height }}></div>
<Spin
spinning={loadedAvatars < contributors.length}
tip={`Loading avatars ${loadedAvatars}/${contributors.length}`}
style={{ maxHeight: 'none' }} // disable maxHeight to make the loading tip be placed in the center
>
<div ref={divEL} style={{ width: '100%', height }} />
</Spin>
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@ import View from './view';
import DataNotFound from '../repo-networks/DataNotFound';
import * as pageDetect from 'github-url-detection';

export interface RepoActivityDetails {
// e.g. 2020-05: [["frank-zsy", 4.69], ["heming6666", 3.46], ["menbotics[bot]", 2]]
[key: string]: [string, number][];
}

const featureId = features.getFeatureID(import.meta.url);
let repoName: string;
let repoActivityDetails: any;
let repoActivityDetails: RepoActivityDetails;

const getData = async () => {
repoActivityDetails = await getActivityDetails(repoName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import optionsStorage, {
defaults,
} from '../../../../options-storage';
import RacingBar from './RacingBar';
import { RepoActivityDetails } from '.';

interface Props {
currentRepo: string;
repoActivityDetails: any;
repoActivityDetails: RepoActivityDetails;
}

const View = ({ currentRepo, repoActivityDetails }: Props): JSX.Element => {
Expand Down
Loading

0 comments on commit 746f7da

Please sign in to comment.