Skip to content

Commit

Permalink
Sponsorship changes (#4274)
Browse files Browse the repository at this point in the history
Rotate sponsors on the top of the page:
- `topIcon` -> `topIconShowEvery`
- `Sponsors` becomes an interface with methods to get levels, pick sponsors etc
- A somewhat acceptable algorithm for generating a "fair choice" of sponsors given the constraints:
  - "show at least 1 in N times"
  - don't unfairly show one sponsor more than any other at the same level
 - Sponsor icons are loaded dynamically via a "bits" handler (introducing a short delay in them appearing), but this means the index.html doesn't change on every load, so it's still cacheable
  • Loading branch information
mattgodbolt committed Nov 13, 2022
1 parent deb24bd commit cfb3dc5
Show file tree
Hide file tree
Showing 9 changed files with 238 additions and 41 deletions.
7 changes: 4 additions & 3 deletions etc/config/sponsors.yaml
@@ -1,4 +1,5 @@
---
maxTopIcons: 3
levels:
- name: Corporate Sponsors
description: 'Enormous thanks to our corporate sponsors. Please visit their websites below:'
Expand All @@ -9,15 +10,15 @@ levels:
img: https://static.ce-cdn.net/SOLID+SANDS-LOGO-RGB-500px.png
url: https://solidsands.com/
priority: 100
topIcon: true
topIconShowEvery: 3
statsId: solid_sands
- name: Intel
description: We engineer solutions for our customers’ greatest challenges with reliable, cloud to edge computing, inspired by Moore’s Law.
icon: https://static.ce-cdn.net/intel/logo-classicblue-3000px-single-colour.png
img: https://static.ce-cdn.net/intel/logo-classicblue-3000px.png
url: https://intel.com/
priority: 400
topIcon: true
topIconShowEvery: 1
sideBySide: true
statsId: intel
- name: Backtrace
Expand All @@ -29,7 +30,7 @@ levels:
img: https://static.ce-cdn.net/bt/BT-logo (1).png
url: https://backtrace.io/sign-up?utm_source=website&utm_medium=paiddisplay&utm_campaign=00O8Y000008JiuiUAC&utm_content=godboltbannerad
priority: 500
topIcon: true
topIconShowEvery: 3
sideBySide: true
statsId: backtrace
- name: Patreon Legends
Expand Down
13 changes: 8 additions & 5 deletions lib/sponsors.interfaces.ts
Expand Up @@ -31,7 +31,7 @@ export type Sponsor = {
url?: string;
onclick: string;
priority: number;
topIcon: boolean;
topIconShowEvery: number;
sideBySide: boolean;
statsId?: string;
};
Expand All @@ -43,7 +43,10 @@ export type Level = {
sponsors: Sponsor[];
};

export type Sponsors = {
icons: Sponsor[];
levels: Level[];
};
export interface Sponsors {
getLevels(): Level[];

pickTopIcons(): Sponsor[];

getAllTopIcons(): Sponsor[];
}
109 changes: 104 additions & 5 deletions lib/sponsors.ts
Expand Up @@ -24,7 +24,7 @@

import yaml from 'yaml';

import {Sponsor, Sponsors} from './sponsors.interfaces';
import {Level, Sponsor, Sponsors} from './sponsors.interfaces';

export function parse(mapOrString: Record<string, any> | string): Sponsor {
if (typeof mapOrString == 'string') mapOrString = {name: mapOrString};
Expand All @@ -36,7 +36,7 @@ export function parse(mapOrString: Record<string, any> | string): Sponsor {
img: mapOrString.img,
icon: mapOrString.icon || mapOrString.img,
icon_dark: mapOrString.icon_dark,
topIcon: !!mapOrString.topIcon,
topIconShowEvery: mapOrString.topIconShowEvery || 0,
sideBySide: !!mapOrString.sideBySide,
priority: mapOrString.priority || 0,
statsId: mapOrString.statsId,
Expand All @@ -50,14 +50,113 @@ function compareSponsors(lhs: Sponsor, rhs: Sponsor): number {
return lhs.name.localeCompare(rhs.name);
}

function calcMean(values: number[]): number {
return values.reduce((x, y) => x + y, 0) / values.length;
}

function squareSumFromMean(values: number[]): number {
const mean = calcMean(values);
return values.reduce((x, y) => x + (y - mean) * (y - mean), 0);
}

function standardDeviation(values: number[]): number {
return values.length < 2 ? 0 : Math.sqrt(squareSumFromMean(values) / (values.length - 1));
}

// A sponsor icon set is ok if:
// - each sponsor is shown at least every "topIconShowEvery"
// - the standard deviation for the number of showings between sponsors at the same "show every' is not too high: that
// is we fairly distribute showings of sponsors at the same level
function sponsorIconSetsOk(
sponsorAppearanceCount: Map<Sponsor, number>,
totalAppearances: number,
maxStandardDeviation: number,
): boolean {
const countsByShowEvery: Map<number, number[]> = new Map();
for (const [icon, count] of sponsorAppearanceCount.entries()) {
const seenEvery = count > 0 ? totalAppearances / count : Infinity;
if (seenEvery > icon.topIconShowEvery) {
return false;
}
const others = countsByShowEvery.get(icon.topIconShowEvery) || [];
others.push(seenEvery);
countsByShowEvery.set(icon.topIconShowEvery, others);
}
return Math.max(...[...countsByShowEvery.values()].map(standardDeviation)) <= maxStandardDeviation;
}

export function makeIconSets(
icons: Sponsor[],
maxIcons: number,
maxIters = 100,
maxStandardDeviation = 0.5,
): Sponsor[][] {
const result: Sponsor[][] = [];
const sponsorAppearanceCount: Map<Sponsor, number> = new Map();
for (const icon of icons) sponsorAppearanceCount.set(icon, 0);
while (!sponsorIconSetsOk(sponsorAppearanceCount, result.length, maxStandardDeviation)) {
if (result.length > maxIters) {
throw new Error(`Unable to find a solution in ${maxIters}`);
}
const toPick = icons.map(icon => {
return {
icon: icon,
// Number of times we'd expect to see this, divided by number of times we saw it
error: result.length / icon.topIconShowEvery / (sponsorAppearanceCount.get(icon) || 0.00001),
};
});
toPick.sort((lhs, rhs) => rhs.error - lhs.error);
const chosen = toPick
.slice(0, maxIcons)
.map(x => x.icon)
.sort(compareSponsors);
for (const c of chosen) sponsorAppearanceCount.set(c, (sponsorAppearanceCount.get(c) || 0) + 1);
result.push(chosen);
}
return result;
}

class SponsorsImpl implements Sponsors {
private readonly _levels: Level[];
private readonly _icons: Sponsor[];
private readonly _iconSets: Sponsor[][];
private _nextSet: number;

constructor(levels: Level[], maxTopIcons) {
this._levels = levels;
this._icons = [];
for (const level of levels) {
this._icons.push(...level.sponsors.filter(sponsor => sponsor.topIconShowEvery && sponsor.icon));
}
this._iconSets = makeIconSets(this._icons, maxTopIcons);
this._nextSet = 0;
}

getLevels(): Level[] {
return this._levels;
}

getAllTopIcons(): Sponsor[] {
return this._icons;
}

pickTopIcons(): Sponsor[] {
const result = this._iconSets[this._nextSet];
this._nextSet = (this._nextSet + 1) % this._iconSets.length;
return result;
}
}

export function loadSponsorsFromLevels(levels: Level[], maxTopIcons: number): Sponsors {
return new SponsorsImpl(levels, maxTopIcons);
}

export function loadSponsorsFromString(stringConfig: string): Sponsors {
const sponsorConfig = yaml.parse(stringConfig);
sponsorConfig.icons = [];
for (const level of sponsorConfig.levels) {
for (const required of ['name', 'description', 'sponsors'])
if (!level[required]) throw new Error(`Level is missing '${required}'`);
level.sponsors = level.sponsors.map(parse).sort(compareSponsors);
sponsorConfig.icons.push(...level.sponsors.filter(sponsor => sponsor.topIcon && sponsor.icon));
}
return sponsorConfig;
return loadSponsorsFromLevels(sponsorConfig.levels, sponsorConfig.maxTopIcons || 3);
}
8 changes: 8 additions & 0 deletions static/main.js
Expand Up @@ -196,6 +196,14 @@ function setupButtons(options, hub) {
alertSystem.alert('Changelog', $(require('./generated/changelog.pug').default.text));
});

$.get(window.location.origin + window.httpRoot + 'bits/icons.html')
.done(function (data) {
$('#ces .ces-icons').html(data);
})
.fail(function (err) {
Sentry.captureException(err);
});

$('#ces').on('click', function () {
$.get(window.location.origin + window.httpRoot + 'bits/sponsors.html')
.done(function (data) {
Expand Down
116 changes: 101 additions & 15 deletions test/sponsors-test.js
Expand Up @@ -22,9 +22,11 @@
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.

import {loadSponsorsFromString, parse} from '../lib/sponsors';
import fs from 'fs';

import {should} from './utils';
import {loadSponsorsFromString, makeIconSets, parse} from '../lib/sponsors';

import {resolvePathFromTestRoot, should} from './utils';

describe('Sponsors', () => {
it('should expand names to objects', () => {
Expand All @@ -41,7 +43,7 @@ describe('Sponsors', () => {
should.equal(obj.img, undefined);
should.equal(obj.icon, undefined);
should.equal(obj.icon_dark, undefined);
obj.topIcon.should.be.false;
obj.topIconShowEvery.should.eq(0);
obj.sideBySide.should.be.false;
should.equal(obj.statsId, undefined);
});
Expand All @@ -64,7 +66,7 @@ describe('Sponsors', () => {
parse({name: 'bob', icon: 'icon', icon_dark: 'icon_dark'}).icon_dark.should.eq('icon_dark');
});
it('should handle topIcons', () => {
parse({name: 'bob', topIcon: true}).topIcon.should.be.true;
parse({name: 'bob', topIconShowEvery: 2}).topIconShowEvery.should.eq(2);
});
it('should handle clicks', () => {
parse({
Expand Down Expand Up @@ -92,9 +94,10 @@ levels:
- Yay
`);
sample.should.not.be.null;
sample.levels.length.should.eq(2);
sample.levels[0].name.should.eq('Patreon Legends');
sample.levels[1].name.should.eq('Patreons');
const levels = sample.getLevels();
levels.length.should.eq(2);
levels[0].name.should.eq('Patreon Legends');
levels[1].name.should.eq('Patreons');
});

it('should sort sponsors by name', () => {
Expand All @@ -108,7 +111,7 @@ levels:
- C
- A
- B
`).levels[0].sponsors;
`).getLevels()[0].sponsors;
peeps.map(sponsor => sponsor.name).should.deep.equals(['A', 'B', 'C', 'D']);
});
it('should sort sponsors by priority then name', () => {
Expand All @@ -124,7 +127,7 @@ levels:
priority: 50
- name: B
priority: 50
`).levels[0].sponsors;
`).getLevels()[0].sponsors;
peeps
.map(sponsor => {
return {name: sponsor.name, priority: sponsor.priority};
Expand All @@ -136,7 +139,7 @@ levels:
]);
});

it('should pick out the top level icons', () => {
it('should pick out all the top level icons', () => {
const icons = loadSponsorsFromString(`
---
levels:
Expand All @@ -145,21 +148,104 @@ levels:
sponsors:
- name: one
img: pick_me
topIcon: true
topIconShowEvery: 1
- name: two
img: not_me
- name: another level
description: more
sponsors:
- name: three
img: not_me_either
topIcon: false
topIconShowEvery: 0
- name: four
img: pick_me_also
topIcon: true
topIconShowEvery: 2
- name: five
topIcon: true
`).icons;
topIconShowEvery: 3
`).getAllTopIcons();
icons.map(s => s.name).should.deep.equals(['one', 'four']);
});

it('should pick icons appropriately when all required every 3', () => {
const sponsor1 = parse({name: 'Sponsor1', topIconShowEvery: 3, icon: '1'});
const sponsor2 = parse({name: 'Sponsor2', topIconShowEvery: 3, icon: '2'});
const sponsor3 = parse({name: 'Sponsor3', topIconShowEvery: 3, icon: '3'});
const icons = [sponsor1, sponsor2, sponsor3];
makeIconSets(icons, 10).should.deep.eq([icons]);
makeIconSets(icons, 3).should.deep.eq([icons]);
makeIconSets(icons, 2).should.deep.eq([
[sponsor1, sponsor2],
[sponsor1, sponsor3],
[sponsor2, sponsor3],
]);
makeIconSets(icons, 1).should.deep.eq([[sponsor1], [sponsor2], [sponsor3]]);
});
it('should pick icons appropriately when not required on different schedules', () => {
const sponsor1 = parse({name: 'Sponsor1', topIconShowEvery: 1, icon: '1'});
const sponsor2 = parse({name: 'Sponsor2', topIconShowEvery: 2, icon: '2'});
const sponsor3 = parse({name: 'Sponsor3', topIconShowEvery: 3, icon: '3'});
const icons = [sponsor1, sponsor2, sponsor3];
makeIconSets(icons, 10).should.deep.eq([icons]);
makeIconSets(icons, 3).should.deep.eq([icons]);
makeIconSets(icons, 2).should.deep.eq([
[sponsor1, sponsor2],
[sponsor1, sponsor3],
]);
(() => makeIconSets(icons, 1)).should.throw();
});
it('should pick icons appropriately with a lot of sponsors on representative schedules', () => {
const sponsor1 = parse({name: 'Sponsor1', topIconShowEvery: 1, icon: '1'});
const sponsor2 = parse({name: 'Sponsor2', topIconShowEvery: 3, icon: '2'});
const sponsor3 = parse({name: 'Sponsor3', topIconShowEvery: 3, icon: '3'});
const sponsor4 = parse({name: 'Sponsor4', topIconShowEvery: 3, icon: '3'});
const sponsor5 = parse({name: 'Sponsor5', topIconShowEvery: 3, icon: '3'});
const icons = [sponsor1, sponsor2, sponsor3, sponsor4, sponsor5];
makeIconSets(icons, 10).should.deep.eq([icons]);
makeIconSets(icons, 3).should.deep.eq([
[sponsor1, sponsor2, sponsor3],
[sponsor1, sponsor4, sponsor5],
]);
(() => makeIconSets(icons, 1)).should.throw();
});
it('should handle alternating', () => {
const sponsor1 = parse({name: 'Sponsor1', topIconShowEvery: 1, icon: '1'});
const sponsor2 = parse({name: 'Sponsor2', topIconShowEvery: 1, icon: '2'});
const sponsor3 = parse({name: 'Sponsor3', topIconShowEvery: 2, icon: '3'});
const sponsor4 = parse({name: 'Sponsor4', topIconShowEvery: 2, icon: '4'});
const icons = [sponsor1, sponsor2, sponsor3, sponsor4];
makeIconSets(icons, 4).should.deep.eq([icons]);
makeIconSets(icons, 3).should.deep.eq([
[sponsor1, sponsor2, sponsor3],
[sponsor1, sponsor2, sponsor4],
]);
(() => makeIconSets(icons, 2)).should.throw();
});
});

describe('Our specific sponsor file', () => {
const stringConfig = fs.readFileSync(resolvePathFromTestRoot('../etc/config/sponsors.yaml')).toString();
it('should parse the current config', () => {
loadSponsorsFromString(stringConfig);
});
it('should pick appropriate sponsor icons', () => {
const numLoads = 100;
const expectedNumIcons = 3;

const sponsors = loadSponsorsFromString(stringConfig);
const picks = [];
for (let load = 0; load < numLoads; ++load) {
picks.push(sponsors.pickTopIcons());
}
const countBySponsor = new Map();
for (const pick of picks) {
for (const sponsor of pick) {
countBySponsor.set(sponsor, (countBySponsor.get(sponsor) || 0) + 1);
}
pick.length.should.eq(expectedNumIcons);
}
for (const topIcon of sponsors.getAllTopIcons()) {
const appearsEvery = countBySponsor.get(topIcon) / numLoads;
appearsEvery.should.lte(topIcon.topIconShowEvery);
}
});
});
6 changes: 6 additions & 0 deletions views/bits/icons.pug
@@ -0,0 +1,6 @@
each icon in sponsors.pickTopIcons()
if !icon.icon_dark || icon.icon === icon.icon_dark
img.ces-icon(src=icon.icon alt=icon.name data-statsid=icon.statsId)
else
img.ces-icon.theme-light-only(src=icon.icon alt=icon.name data-statsid=icon.statsId)
img.ces-icon.theme-dark-only(src=icon.icon_dark alt=icon.name data-statsid=icon.statsId)

0 comments on commit cfb3dc5

Please sign in to comment.