Skip to content

Commit d4ae1a8

Browse files
committed
feat: making colors more meaningful
1 parent 3d3e655 commit d4ae1a8

2 files changed

Lines changed: 162 additions & 19 deletions

File tree

web/src/contributions/explore.js

Lines changed: 78 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@ const _ROLE_COLOR = {
5757
'Funding Acquisition': '#4455BB', // darker indigo-blue
5858

5959
// ── Group 2: Data acquisition (green → teal-cyan) ───────────────────────
60-
'Methodology': '#00BB55', // vibrant green
60+
'Methodology': '#DB9500', // ochre (CMYK 0/32/100/14)
6161
'Validation': '#009950', // medium-dark green
6262
'Investigation': '#00AA88', // teal-green
63-
'Resources': '#0099BB', // teal-blue
63+
'Resources': '#c38a0e', // ochre (CMYK 0/32/100/14)
6464
'Data curation': '#00BBCC', // cyan-teal
6565

6666
// ── Group 3: Analysis & writing (pink → red-pink / magenta) ────────────
@@ -77,11 +77,29 @@ const _ROLE_ABBREV = (r) => r
7777
.replace('Writing \u2013 ', 'W: ')
7878
.replace('Formal analysis', 'Formal anal.');
7979

80-
const _PALETTE = [
81-
'#4f46e5', '#0d9488', '#7c3aed', '#d97706',
82-
'#e11d48', '#059669', '#1e40af', '#4338ca',
83-
'#be185d', '#0891b2', '#65a30d', '#9333ea',
84-
];
80+
// ── Author node color — semantic group derived from majority CRediT roles ──
81+
//
82+
// Group → hue band [center°, halfSpread°]
83+
const _NODE_HUE = {
84+
leadership: [252, 32], // blue-violet → violet-purple (~220–284°)
85+
methods: [ 41, 22], // ochre → amber (~19–63°) (Methodology, Resources)
86+
data: [165, 28], // green → teal (~137–193°)
87+
analysis: [340, 22], // pink → magenta (~318–362°)
88+
};
89+
90+
// Derived from _ROLE_COLOR comment groups above.
91+
const _NODE_ROLE_GROUP = (() => {
92+
const m = {};
93+
for (const r of ['Conceptualization', 'Supervision', 'Project Administration', 'Funding Acquisition'])
94+
m[r.toLowerCase()] = 'leadership';
95+
for (const r of ['Methodology', 'Resources'])
96+
m[r.toLowerCase()] = 'methods';
97+
for (const r of ['Validation', 'Investigation', 'Data curation'])
98+
m[r.toLowerCase()] = 'data';
99+
for (const r of ['Formal analysis', 'Software', 'Writing \u2013 original draft', 'Writing \u2013 review & editing', 'Visualization'])
100+
m[r.toLowerCase()] = 'analysis';
101+
return m;
102+
})();
85103

86104
// ── Utilities ─────────────────────────────────────────────────────────────────
87105

@@ -91,7 +109,55 @@ function _hash(s) {
91109
return Math.abs(h);
92110
}
93111

94-
function _nodeColor(name) { return _PALETTE[_hash(name) % _PALETTE.length]; }
112+
function _nodeColor(author, allAuthors) {
113+
const counts = { leadership: 0, methods: 0, data: 0, analysis: 0 };
114+
115+
// Own contributions (weight 1.0 each)
116+
const ownRoles = new Set();
117+
if (author.credit_levels) {
118+
for (const cl of author.credit_levels) {
119+
if (!cl.role) continue;
120+
const key = cl.role.toLowerCase().replace(/\s+/g, ' ').replace(/\u2014/g, '\u2013').trim();
121+
ownRoles.add(key);
122+
const grp = _NODE_ROLE_GROUP[key];
123+
if (grp) counts[grp]++;
124+
}
125+
}
126+
127+
// Co-contributor influence: authors who share ≥1 role each add 0.1 per role
128+
if (allAuthors && ownRoles.size > 0) {
129+
for (const other of allAuthors) {
130+
if (other.name === author.name || !other.credit_levels) continue;
131+
const shares = other.credit_levels.some(cl => {
132+
const key = cl.role.toLowerCase().replace(/\s+/g, ' ').replace(/\u2014/g, '\u2013').trim();
133+
return ownRoles.has(key);
134+
});
135+
if (!shares) continue;
136+
for (const cl of other.credit_levels) {
137+
if (!cl.role) continue;
138+
const key = cl.role.toLowerCase().replace(/\s+/g, ' ').replace(/\u2014/g, '\u2013').trim();
139+
const grp = _NODE_ROLE_GROUP[key];
140+
if (grp) counts[grp] += 0.1;
141+
}
142+
}
143+
}
144+
145+
const best = Math.max(counts.leadership, counts.methods, counts.data, counts.analysis);
146+
let group;
147+
if (best === 0) {
148+
group = ['leadership', 'methods', 'data', 'analysis'][_hash(author.name) % 4];
149+
} else {
150+
const tied = Object.entries(counts).filter(([, v]) => v === best).map(([k]) => k);
151+
group = tied.length === 1 ? tied[0] : tied[_hash(author.name) % tied.length];
152+
}
153+
const h1 = _hash(author.name);
154+
const h2 = _hash(author.name + '~');
155+
const [hCenter, hHalf] = _NODE_HUE[group];
156+
const hue = ((hCenter - hHalf + (h1 % (hHalf * 2 + 1))) + 360) % 360;
157+
const sat = 62 + (h2 % 18); // 62–79 %
158+
const lgt = 40 + ((h1 >> 6) % 14); // 40–53 %
159+
return `hsl(${hue},${sat}%,${lgt}%)`;
160+
}
95161

96162
function _initials(name) {
97163
const p = name.trim().split(/\s+/);
@@ -333,6 +399,7 @@ function _injectCSS() {
333399

334400
let _xpopEl = null;
335401
let _xpopTid = null;
402+
let _xAllAuthors = []; // set by createExploreView for use in _showXpop
336403

337404
function _showXpop(anchorEl, author) {
338405
_hideXpop();
@@ -358,7 +425,7 @@ function _showXpop(anchorEl, author) {
358425
const header = document.createElement('div');
359426
header.style.cssText = 'display:flex;align-items:center;gap:9px;margin-bottom:7px;';
360427
const avEl = document.createElement('div');
361-
const avColor = _nodeColor(author.name);
428+
const avColor = _nodeColor(author, _xAllAuthors);
362429
avEl.style.cssText = [
363430
`background:${avColor}`, 'border-radius:50%',
364431
'width:30px', 'height:30px', 'flex-shrink:0',
@@ -648,7 +715,7 @@ function _renderGraph(svgEl, nodes, edges, width, height) {
648715

649716
for (const node of nodes) {
650717
const { author, i } = node;
651-
const color = _nodeColor(author.name);
718+
const color = _nodeColor(author, _xAllAuthors);
652719

653720
const g = _svg('g', {
654721
class: 'ae-explore-node',
@@ -913,6 +980,7 @@ function _buildRightLegend(el, clusterMap, onHover, onLeave) {
913980
* @returns {Function} - Cleanup function; call when unmounting.
914981
*/
915982
export function createExploreView(container, authors, zoomState) {
983+
_xAllAuthors = authors || [];
916984
_injectCSS();
917985
if (!authors || authors.length === 0) return () => {};
918986

web/src/contributions/preview.js

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,32 @@ const ROLE_ICONS = {
3535
'Supervision': '👥', 'Project Administration': '📋', 'Funding Acquisition': '💰',
3636
};
3737

38-
const AVATAR_COLORS = [
39-
'#4f46e5', '#0d9488', '#7c3aed', '#d97706',
40-
'#e11d48', '#059669', '#1e40af', '#4338ca',
41-
];
38+
// Maps each CRediT role to a semantic color group.
39+
// Initialized after normalizeRole() hoists as a function declaration.
40+
const ROLE_GROUP = (() => {
41+
const m = {};
42+
for (const r of ['Conceptualization', 'Supervision', 'Project Administration', 'Funding Acquisition'])
43+
m[normalizeRole(r)] = 'leadership'; // blue / purple
44+
for (const r of ['Methodology', 'Resources'])
45+
m[normalizeRole(r)] = 'methods'; // ochre
46+
for (const r of ['Validation', 'Investigation', 'Data curation'])
47+
m[normalizeRole(r)] = 'data'; // green / teal
48+
for (const r of ['Formal analysis', 'Software', 'Writing \u2013 original draft', 'Writing \u2013 review & editing', 'Visualization'])
49+
m[normalizeRole(r)] = 'analysis'; // red / pink / magenta
50+
return m;
51+
})();
52+
53+
// Hue [center, halfSpread] for each group (degrees)
54+
const GROUP_HUE = {
55+
leadership: [252, 32], // ~220–284 blue-violet → violet-purple
56+
methods: [ 41, 22], // ~19–63 ochre → amber (Methodology, Resources)
57+
data: [165, 28], // ~137–193 green → teal
58+
analysis: [340, 22], // ~318–362 pink → magenta (wraps through 0°)
59+
};
60+
61+
// Module-level cache of all authors from the most recent createPreview() call.
62+
// Used by getColor() to incorporate co-contributor network weighting.
63+
let _allAuthors = [];
4264

4365
const LEVEL_RANK = { lead: 3, equal: 2, supporting: 1 };
4466

@@ -302,8 +324,60 @@ function hashStr(s) {
302324
return Math.abs(h);
303325
}
304326

305-
function getColor(name) {
306-
return AVATAR_COLORS[hashStr(name) % AVATAR_COLORS.length];
327+
/**
328+
* Assign an author a color derived from their majority CRediT group:
329+
* leadership → blue/purple, data → green/teal, analysis → red/pink.
330+
* Within that group the hue, saturation, and lightness are randomized
331+
* deterministically from the author's name so the same author always
332+
* gets the same color regardless of sort order.
333+
*
334+
* @param {{ name: string, credit_levels?: Array<{role:string,level:string}> }} author
335+
*/
336+
function getColor(author) {
337+
const counts = { leadership: 0, methods: 0, data: 0, analysis: 0 };
338+
339+
// Own contributions (weight 1.0 each)
340+
const ownRoles = new Set();
341+
if (author.credit_levels) {
342+
for (const cl of author.credit_levels) {
343+
const norm = normalizeRole(cl.role);
344+
ownRoles.add(norm);
345+
const grp = ROLE_GROUP[norm];
346+
if (grp) counts[grp]++;
347+
}
348+
}
349+
350+
// Co-contributor influence: authors who share ≥1 role each add 0.1 per role
351+
if (_allAuthors.length > 0 && ownRoles.size > 0) {
352+
for (const other of _allAuthors) {
353+
if (other.name === author.name || !other.credit_levels) continue;
354+
const shares = other.credit_levels.some(cl => ownRoles.has(normalizeRole(cl.role)));
355+
if (!shares) continue;
356+
for (const cl of other.credit_levels) {
357+
const grp = ROLE_GROUP[normalizeRole(cl.role)];
358+
if (grp) counts[grp] += 0.1;
359+
}
360+
}
361+
}
362+
363+
// Find the majority group; break ties with the name hash.
364+
const best = Math.max(counts.leadership, counts.methods, counts.data, counts.analysis);
365+
let group;
366+
if (best === 0) {
367+
group = ['leadership', 'methods', 'data', 'analysis'][hashStr(author.name) % 4];
368+
} else {
369+
const tied = Object.entries(counts).filter(([, v]) => v === best).map(([k]) => k);
370+
group = tied.length === 1 ? tied[0] : tied[hashStr(author.name) % tied.length];
371+
}
372+
373+
// Two independent pseudo-random streams seeded by name.
374+
const h1 = hashStr(author.name);
375+
const h2 = hashStr(author.name + '~');
376+
const [hCenter, hHalf] = GROUP_HUE[group];
377+
const hue = ((hCenter - hHalf + (h1 % (hHalf * 2 + 1))) + 360) % 360;
378+
const sat = 62 + (h2 % 18); // 62–79 %
379+
const lgt = 40 + ((h1 >> 6) % 14); // 40–53 %
380+
return `hsl(${hue},${sat}%,${lgt}%)`;
307381
}
308382

309383
function getInitials(name) {
@@ -419,7 +493,7 @@ function attachAuthorPopover(element, author) {
419493
function showPopover(anchor, author) {
420494
hidePopover();
421495
ensurePopoverStyles();
422-
const color = getColor(author.name);
496+
const color = getColor(author);
423497
const pop = el('div', { className: 'ae-popover' });
424498
pop.addEventListener('mouseenter', () => clearTimeout(_popoverTimeout));
425499
pop.addEventListener('mouseleave', () => {
@@ -527,6 +601,7 @@ function sortAuthors(authors, sortKey) {
527601
*/
528602
export function createPreview(container, authors) {
529603
ensureWidgetCSS();
604+
_allAuthors = authors || [];
530605

531606
// Remove previous widget if any
532607
const prev = container.querySelector('.ae-widget');
@@ -924,7 +999,7 @@ export function createPreview(container, authors) {
924999
for (let ai = 0; ai < sorted.length; ai++) {
9251000
const author = sorted[ai];
9261001
const isDimmed = searchQuery && !matchesSearch(author, searchQuery);
927-
const color = getColor(author.name);
1002+
const color = getColor(author);
9281003
const card = el('div', { className: 'ae-profile-card' });
9291004
card.style.setProperty('--i', String(ai));
9301005
if (isDimmed) card.style.opacity = '0.3';
@@ -1042,7 +1117,7 @@ export function createPreview(container, authors) {
10421117

10431118
const contributors = el('div', { className: 'ae-section-contributors' });
10441119
for (const c of contribs) {
1045-
const color = getColor(c.author.name);
1120+
const color = getColor(c.author);
10461121
const isDimmed = searchQuery && !matchesSearch(c.author, searchQuery);
10471122
const chip = el('div', { className: 'ae-section-chip' });
10481123
if (isDimmed) chip.style.opacity = '0.3';

0 commit comments

Comments
 (0)