Sponsorship changes (#4274)

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
This commit is contained in:
Matt Godbolt
2022-11-13 10:35:39 -06:00
committed by GitHub
parent deb24bd942
commit cfb3dc54c7
9 changed files with 238 additions and 41 deletions

View File

@@ -1,4 +1,5 @@
--- ---
maxTopIcons: 3
levels: levels:
- name: Corporate Sponsors - name: Corporate Sponsors
description: 'Enormous thanks to our corporate sponsors. Please visit their websites below:' description: 'Enormous thanks to our corporate sponsors. Please visit their websites below:'
@@ -9,7 +10,7 @@ levels:
img: https://static.ce-cdn.net/SOLID+SANDS-LOGO-RGB-500px.png img: https://static.ce-cdn.net/SOLID+SANDS-LOGO-RGB-500px.png
url: https://solidsands.com/ url: https://solidsands.com/
priority: 100 priority: 100
topIcon: true topIconShowEvery: 3
statsId: solid_sands statsId: solid_sands
- name: Intel - name: Intel
description: We engineer solutions for our customers greatest challenges with reliable, cloud to edge computing, inspired by Moores Law. description: We engineer solutions for our customers greatest challenges with reliable, cloud to edge computing, inspired by Moores Law.
@@ -17,7 +18,7 @@ levels:
img: https://static.ce-cdn.net/intel/logo-classicblue-3000px.png img: https://static.ce-cdn.net/intel/logo-classicblue-3000px.png
url: https://intel.com/ url: https://intel.com/
priority: 400 priority: 400
topIcon: true topIconShowEvery: 1
sideBySide: true sideBySide: true
statsId: intel statsId: intel
- name: Backtrace - name: Backtrace
@@ -29,7 +30,7 @@ levels:
img: https://static.ce-cdn.net/bt/BT-logo (1).png 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 url: https://backtrace.io/sign-up?utm_source=website&utm_medium=paiddisplay&utm_campaign=00O8Y000008JiuiUAC&utm_content=godboltbannerad
priority: 500 priority: 500
topIcon: true topIconShowEvery: 3
sideBySide: true sideBySide: true
statsId: backtrace statsId: backtrace
- name: Patreon Legends - name: Patreon Legends

View File

@@ -31,7 +31,7 @@ export type Sponsor = {
url?: string; url?: string;
onclick: string; onclick: string;
priority: number; priority: number;
topIcon: boolean; topIconShowEvery: number;
sideBySide: boolean; sideBySide: boolean;
statsId?: string; statsId?: string;
}; };
@@ -43,7 +43,10 @@ export type Level = {
sponsors: Sponsor[]; sponsors: Sponsor[];
}; };
export type Sponsors = { export interface Sponsors {
icons: Sponsor[]; getLevels(): Level[];
levels: Level[];
}; pickTopIcons(): Sponsor[];
getAllTopIcons(): Sponsor[];
}

View File

@@ -24,7 +24,7 @@
import yaml from 'yaml'; 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 { export function parse(mapOrString: Record<string, any> | string): Sponsor {
if (typeof mapOrString == 'string') mapOrString = {name: mapOrString}; if (typeof mapOrString == 'string') mapOrString = {name: mapOrString};
@@ -36,7 +36,7 @@ export function parse(mapOrString: Record<string, any> | string): Sponsor {
img: mapOrString.img, img: mapOrString.img,
icon: mapOrString.icon || mapOrString.img, icon: mapOrString.icon || mapOrString.img,
icon_dark: mapOrString.icon_dark, icon_dark: mapOrString.icon_dark,
topIcon: !!mapOrString.topIcon, topIconShowEvery: mapOrString.topIconShowEvery || 0,
sideBySide: !!mapOrString.sideBySide, sideBySide: !!mapOrString.sideBySide,
priority: mapOrString.priority || 0, priority: mapOrString.priority || 0,
statsId: mapOrString.statsId, statsId: mapOrString.statsId,
@@ -50,14 +50,113 @@ function compareSponsors(lhs: Sponsor, rhs: Sponsor): number {
return lhs.name.localeCompare(rhs.name); 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 { export function loadSponsorsFromString(stringConfig: string): Sponsors {
const sponsorConfig = yaml.parse(stringConfig); const sponsorConfig = yaml.parse(stringConfig);
sponsorConfig.icons = [];
for (const level of sponsorConfig.levels) { for (const level of sponsorConfig.levels) {
for (const required of ['name', 'description', 'sponsors']) for (const required of ['name', 'description', 'sponsors'])
if (!level[required]) throw new Error(`Level is missing '${required}'`); if (!level[required]) throw new Error(`Level is missing '${required}'`);
level.sponsors = level.sponsors.map(parse).sort(compareSponsors); 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);
} }

View File

@@ -196,6 +196,14 @@ function setupButtons(options, hub) {
alertSystem.alert('Changelog', $(require('./generated/changelog.pug').default.text)); 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 () { $('#ces').on('click', function () {
$.get(window.location.origin + window.httpRoot + 'bits/sponsors.html') $.get(window.location.origin + window.httpRoot + 'bits/sponsors.html')
.done(function (data) { .done(function (data) {

View File

@@ -22,9 +22,11 @@
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE // ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE. // 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', () => { describe('Sponsors', () => {
it('should expand names to objects', () => { it('should expand names to objects', () => {
@@ -41,7 +43,7 @@ describe('Sponsors', () => {
should.equal(obj.img, undefined); should.equal(obj.img, undefined);
should.equal(obj.icon, undefined); should.equal(obj.icon, undefined);
should.equal(obj.icon_dark, undefined); should.equal(obj.icon_dark, undefined);
obj.topIcon.should.be.false; obj.topIconShowEvery.should.eq(0);
obj.sideBySide.should.be.false; obj.sideBySide.should.be.false;
should.equal(obj.statsId, undefined); should.equal(obj.statsId, undefined);
}); });
@@ -64,7 +66,7 @@ describe('Sponsors', () => {
parse({name: 'bob', icon: 'icon', icon_dark: 'icon_dark'}).icon_dark.should.eq('icon_dark'); parse({name: 'bob', icon: 'icon', icon_dark: 'icon_dark'}).icon_dark.should.eq('icon_dark');
}); });
it('should handle topIcons', () => { 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', () => { it('should handle clicks', () => {
parse({ parse({
@@ -92,9 +94,10 @@ levels:
- Yay - Yay
`); `);
sample.should.not.be.null; sample.should.not.be.null;
sample.levels.length.should.eq(2); const levels = sample.getLevels();
sample.levels[0].name.should.eq('Patreon Legends'); levels.length.should.eq(2);
sample.levels[1].name.should.eq('Patreons'); levels[0].name.should.eq('Patreon Legends');
levels[1].name.should.eq('Patreons');
}); });
it('should sort sponsors by name', () => { it('should sort sponsors by name', () => {
@@ -108,7 +111,7 @@ levels:
- C - C
- A - A
- B - B
`).levels[0].sponsors; `).getLevels()[0].sponsors;
peeps.map(sponsor => sponsor.name).should.deep.equals(['A', 'B', 'C', 'D']); peeps.map(sponsor => sponsor.name).should.deep.equals(['A', 'B', 'C', 'D']);
}); });
it('should sort sponsors by priority then name', () => { it('should sort sponsors by priority then name', () => {
@@ -124,7 +127,7 @@ levels:
priority: 50 priority: 50
- name: B - name: B
priority: 50 priority: 50
`).levels[0].sponsors; `).getLevels()[0].sponsors;
peeps peeps
.map(sponsor => { .map(sponsor => {
return {name: sponsor.name, priority: sponsor.priority}; return {name: sponsor.name, priority: sponsor.priority};
@@ -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(` const icons = loadSponsorsFromString(`
--- ---
levels: levels:
@@ -145,7 +148,7 @@ levels:
sponsors: sponsors:
- name: one - name: one
img: pick_me img: pick_me
topIcon: true topIconShowEvery: 1
- name: two - name: two
img: not_me img: not_me
- name: another level - name: another level
@@ -153,13 +156,96 @@ levels:
sponsors: sponsors:
- name: three - name: three
img: not_me_either img: not_me_either
topIcon: false topIconShowEvery: 0
- name: four - name: four
img: pick_me_also img: pick_me_also
topIcon: true topIconShowEvery: 2
- name: five - name: five
topIcon: true topIconShowEvery: 3
`).icons; `).getAllTopIcons();
icons.map(s => s.name).should.deep.equals(['one', 'four']); 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
views/bits/icons.pug Normal file
View File

@@ -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)

View File

@@ -18,7 +18,7 @@ block content
a(href="mailto:matt@godbolt.org") email Matt a(href="mailto:matt@godbolt.org") email Matt
| &nbsp;for corporate sponsorship. | &nbsp;for corporate sponsorship.
.mt-6 .mt-6
each level, index in sponsors.levels each level, index in sponsors.getLevels()
.ces-item-block(class=level.class id="ces_hop_" + index) .ces-item-block(class=level.class id="ces_hop_" + index)
h2.ces-item-name= level.name h2.ces-item-name= level.name
div.ces-item-description= level.description div.ces-item-description= level.description

View File

@@ -1,6 +1,6 @@
div.ces-level-selectors.d-flex.justify-content-center.mt-3 div.ces-level-selectors.d-flex.justify-content-center.mt-3
div.btn-group.btn-group div.btn-group.btn-group
each level, index in sponsors.levels each level, index in sponsors.getLevels()
button.btn.btn-primary(onclick='document.getElementById("ces_hop_' + index + '").scrollIntoView({behavior:"smooth"});')=level.name button.btn.btn-primary(onclick='document.getElementById("ces_hop_' + index + '").scrollIntoView({behavior:"smooth"});')=level.name
block content block content

View File

@@ -1,12 +1,6 @@
.ces-icons .ces-icons
each icon in sponsors.icons if noscript
if noscript each icon in sponsors.pickTopIcons()
a(href=icon.url) a(href=icon.url)
img.ces-icon(src=icon.icon alt=icon.name data-statsid=icon.statsId) img.ces-icon(src=icon.icon alt=icon.name data-statsid=icon.statsId)
span &nbsp; span &nbsp;
else
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)