Skip to content

Commit 8ceb17e

Browse files
ericyangpanclaude
andcommitted
feat: enhance vendor matrix sorting and UI consistency
- Implement sophisticated vendor sorting algorithm with priority-based column grouping - Update vendor type categorization logic (model-only and provider-only) - Merge duplicate projects by GitHub URL in open-source rank - Remove border-radius from UI components for consistent design - Add type="button" attributes to filter buttons - Simplify empty matrix cell rendering - Update vendor type descriptions for clarity 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e980e6b commit 8ceb17e

File tree

3 files changed

+132
-24
lines changed

3 files changed

+132
-24
lines changed

src/app/[locale]/ai-coding-landscape/components/VendorMatrix.tsx

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,7 @@ function MatrixCell({ products, category }: MatrixCellProps) {
3535

3636
if (products.length === 0) {
3737
return (
38-
<div className="h-full min-h-[80px] border border-dashed border-[var(--color-border)] bg-[var(--color-bg-subtle)] flex items-center justify-center">
39-
<span className="text-[var(--color-text-muted)] text-sm">-</span>
40-
</div>
38+
<div className="h-full min-h-[80px] border border-dashed border-[var(--color-border)] bg-[var(--color-bg-subtle)]" />
4139
)
4240
}
4341

@@ -123,10 +121,79 @@ export default function VendorMatrix({ matrixData }: VendorMatrixProps) {
123121
if (sortBy === 'name') {
124122
return a.vendorName.localeCompare(b.vendorName)
125123
}
126-
// Sort by total products (descending)
127-
const aTotal = Object.values(a.cells).reduce((sum, arr) => sum + arr.length, 0)
128-
const bTotal = Object.values(b.cells).reduce((sum, arr) => sum + arr.length, 0)
129-
return bTotal - aTotal
124+
125+
// Define column groups: first part (high priority) and second part (low priority)
126+
const FIRST_PART_CATEGORIES = PRODUCT_CATEGORIES.slice(0, 3) // IDE, CLI, Extension
127+
const SECOND_PART_CATEGORIES = PRODUCT_CATEGORIES.slice(3) // Model, Provider
128+
129+
// Helper function to get column count for a specific set of categories
130+
const getColumnCount = (row: VendorMatrixRow, categories: typeof PRODUCT_CATEGORIES) => {
131+
return categories.filter(cat => row.cells[cat.key] && row.cells[cat.key].length > 0).length
132+
}
133+
134+
// Helper function to get column order (left to right) for a specific set of categories
135+
const getColumnOrder = (row: VendorMatrixRow, categories: typeof PRODUCT_CATEGORIES) => {
136+
return categories
137+
.filter(cat => row.cells[cat.key] && row.cells[cat.key].length > 0)
138+
.map(cat => cat.key)
139+
}
140+
141+
// Helper function to compare column order
142+
const compareColumnOrder = (
143+
aOrder: string[],
144+
bOrder: string[],
145+
categories: typeof PRODUCT_CATEGORIES
146+
) => {
147+
for (let i = 0; i < Math.min(aOrder.length, bOrder.length); i++) {
148+
const aIndex = categories.findIndex(cat => cat.key === aOrder[i])
149+
const bIndex = categories.findIndex(cat => cat.key === bOrder[i])
150+
if (aIndex !== bIndex) {
151+
return aIndex - bIndex
152+
}
153+
}
154+
return 0
155+
}
156+
157+
// 1. Sort by first part column count (descending)
158+
const aFirstPartCount = getColumnCount(a, FIRST_PART_CATEGORIES)
159+
const bFirstPartCount = getColumnCount(b, FIRST_PART_CATEGORIES)
160+
if (aFirstPartCount !== bFirstPartCount) {
161+
return bFirstPartCount - aFirstPartCount
162+
}
163+
164+
// 2. If first part column count is the same, sort by second part column count (descending)
165+
const aSecondPartCount = getColumnCount(a, SECOND_PART_CATEGORIES)
166+
const bSecondPartCount = getColumnCount(b, SECOND_PART_CATEGORIES)
167+
if (aSecondPartCount !== bSecondPartCount) {
168+
return bSecondPartCount - aSecondPartCount
169+
}
170+
171+
// 3. If both column counts are the same, sort by first part column order (left to right)
172+
const aFirstPartOrder = getColumnOrder(a, FIRST_PART_CATEGORIES)
173+
const bFirstPartOrder = getColumnOrder(b, FIRST_PART_CATEGORIES)
174+
const firstPartOrderComparison = compareColumnOrder(
175+
aFirstPartOrder,
176+
bFirstPartOrder,
177+
FIRST_PART_CATEGORIES
178+
)
179+
if (firstPartOrderComparison !== 0) {
180+
return firstPartOrderComparison
181+
}
182+
183+
// 4. If first part order is also the same, sort by second part column order (left to right)
184+
const aSecondPartOrder = getColumnOrder(a, SECOND_PART_CATEGORIES)
185+
const bSecondPartOrder = getColumnOrder(b, SECOND_PART_CATEGORIES)
186+
const secondPartOrderComparison = compareColumnOrder(
187+
aSecondPartOrder,
188+
bSecondPartOrder,
189+
SECOND_PART_CATEGORIES
190+
)
191+
if (secondPartOrderComparison !== 0) {
192+
return secondPartOrderComparison
193+
}
194+
195+
// 5. If everything is the same, sort alphabetically by vendor name
196+
return a.vendorName.localeCompare(b.vendorName)
130197
})
131198

132199
return sorted
@@ -276,10 +343,10 @@ export default function VendorMatrix({ matrixData }: VendorMatrixProps) {
276343
<span className="font-medium">Tool Only:</span> IDE/CLI/Extension
277344
</span>
278345
<span>
279-
<span className="font-medium">Model Only:</span> Model Provider
346+
<span className="font-medium">Model Only:</span> Model (with or without Provider)
280347
</span>
281348
<span>
282-
<span className="font-medium">Provider Only:</span> API Provider
349+
<span className="font-medium">Provider Only:</span> Provider only
283350
</span>
284351
</div>
285352
</div>

src/app/[locale]/open-source-rank/page.client.tsx

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ function getLicenseDisplayName(license: string): string {
2525
return license
2626
}
2727

28-
function getProductTypeName(type: ProductType, t: any): string {
28+
function getProductTypeName(type: ProductType, t: (key: string) => string): string {
2929
switch (type) {
3030
case 'ide':
3131
return t('productType.ide')
@@ -133,13 +133,41 @@ export function OpenSourceRankPage() {
133133
}
134134
})
135135

136-
// Sort by stars (descending)
137-
openSource.sort((a, b) => b.stars - a.stars)
138-
proprietary.sort((a, b) => b.stars - a.stars)
136+
// Merge projects with the same GitHub URL, keeping the one with shorter name
137+
const mergeByGitHubUrl = (projects: OpenSourceProject[]): OpenSourceProject[] => {
138+
const urlMap = new Map<string, OpenSourceProject>()
139+
140+
projects.forEach(project => {
141+
if (!project.githubUrl) {
142+
// Keep projects without GitHub URL as-is
143+
urlMap.set(`no-url-${project.id}`, project)
144+
return
145+
}
146+
147+
const existing = urlMap.get(project.githubUrl)
148+
if (!existing) {
149+
urlMap.set(project.githubUrl, project)
150+
} else {
151+
// Keep the one with shorter name
152+
if (project.name.length < existing.name.length) {
153+
urlMap.set(project.githubUrl, project)
154+
}
155+
}
156+
})
157+
158+
return Array.from(urlMap.values())
159+
}
160+
161+
// Merge first, then sort by stars (descending)
162+
const mergedOpenSource = mergeByGitHubUrl(openSource)
163+
const mergedProprietary = mergeByGitHubUrl(proprietary)
164+
165+
mergedOpenSource.sort((a, b) => b.stars - a.stars)
166+
mergedProprietary.sort((a, b) => b.stars - a.stars)
139167

140168
return {
141-
openSourceProjects: openSource,
142-
proprietaryProjects: proprietary,
169+
openSourceProjects: mergedOpenSource,
170+
proprietaryProjects: mergedProprietary,
143171
}
144172
}, [])
145173

@@ -189,6 +217,7 @@ export function OpenSourceRankPage() {
189217
{/* Filter Section */}
190218
<div className="mb-[var(--spacing-md)] flex gap-[var(--spacing-xs)] flex-wrap">
191219
<button
220+
type="button"
192221
onClick={() => setSelectedType('all')}
193222
className={`px-[var(--spacing-sm)] py-[var(--spacing-xs)] text-sm border transition-all ${
194223
selectedType === 'all'
@@ -199,6 +228,7 @@ export function OpenSourceRankPage() {
199228
{t('filter.all')} ({openSourceProjects.length + proprietaryProjects.length})
200229
</button>
201230
<button
231+
type="button"
202232
onClick={() => setSelectedType('ide')}
203233
className={`px-[var(--spacing-sm)] py-[var(--spacing-xs)] text-sm border transition-all ${
204234
selectedType === 'ide'
@@ -212,6 +242,7 @@ export function OpenSourceRankPage() {
212242
)
213243
</button>
214244
<button
245+
type="button"
215246
onClick={() => setSelectedType('cli')}
216247
className={`px-[var(--spacing-sm)] py-[var(--spacing-xs)] text-sm border transition-all ${
217248
selectedType === 'cli'
@@ -225,6 +256,7 @@ export function OpenSourceRankPage() {
225256
)
226257
</button>
227258
<button
259+
type="button"
228260
onClick={() => setSelectedType('extension')}
229261
className={`px-[var(--spacing-sm)] py-[var(--spacing-xs)] text-sm border transition-all ${
230262
selectedType === 'extension'
@@ -283,7 +315,7 @@ export function OpenSourceRankPage() {
283315
</Link>
284316
</td>
285317
<td className="px-[var(--spacing-sm)] py-[var(--spacing-sm)] text-sm">
286-
<span className="inline-block px-2 py-0.5 text-xs border border-[var(--color-border)] rounded">
318+
<span className="inline-block px-2 py-0.5 text-xs border border-[var(--color-border)]">
287319
{getProductTypeName(project.type, t)}
288320
</span>
289321
</td>
@@ -356,6 +388,11 @@ export function OpenSourceRankPage() {
356388
)
357389
})()}
358390

391+
{/* Note Section */}
392+
<div className="mt-[var(--spacing-lg)] mb-[var(--spacing-lg)] p-[var(--spacing-sm)] border border-[var(--color-border)] bg-[var(--color-hover)] text-sm text-[var(--color-text-secondary)]">
393+
{t('note')}
394+
</div>
395+
359396
{/* Statistics Section with Pie Chart */}
360397
<div className="mt-[var(--spacing-lg)] border border-[var(--color-border)] p-[var(--spacing-md)]">
361398
<h2 className="text-xl font-semibold mb-[var(--spacing-md)]">{t('statistics.title')}</h2>
@@ -461,10 +498,7 @@ export function OpenSourceRankPage() {
461498
key={stat.license}
462499
className="border border-[var(--color-border)] p-[var(--spacing-sm)] flex items-center gap-[var(--spacing-sm)]"
463500
>
464-
<div
465-
className="w-4 h-4 rounded-sm flex-shrink-0"
466-
style={{ backgroundColor: color }}
467-
/>
501+
<div className="w-4 h-4 flex-shrink-0" style={{ backgroundColor: color }} />
468502
<div className="flex-1 min-w-0">
469503
<div className="text-sm font-medium truncate">{stat.license}</div>
470504
<div className="text-xs text-[var(--color-text-secondary)]">
@@ -477,7 +511,7 @@ export function OpenSourceRankPage() {
477511

478512
{/* Proprietary */}
479513
<div className="border border-[var(--color-border)] p-[var(--spacing-sm)] flex items-center gap-[var(--spacing-sm)]">
480-
<div className="w-4 h-4 rounded-sm flex-shrink-0 bg-gray-300" />
514+
<div className="w-4 h-4 flex-shrink-0 bg-gray-300" />
481515
<div className="flex-1 min-w-0">
482516
<div className="text-sm font-medium truncate">Proprietary</div>
483517
<div className="text-xs text-[var(--color-text-secondary)]">

src/lib/landscape-data.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ export type VendorType =
6565
| 'full-stack' // Has IDE + CLI + Extension
6666
| 'ai-native' // Has Model + (IDE or CLI or Extension)
6767
| 'tool-only' // Only has IDE/CLI/Extension
68-
| 'model-only' // Only has Model/Provider
69-
| 'provider-only' // Only has Provider
68+
| 'model-only' // Only has Model, or has Model + Provider (no Tools)
69+
| 'provider-only' // Only has Provider (no Model, no Tools)
7070

7171
export interface ExtensionIDECompatibility {
7272
extensionId: string
@@ -318,10 +318,17 @@ function determineVendorType(products: {
318318
return 'tool-only'
319319
}
320320

321-
if (hasModel && !hasProvider && !hasTools) {
321+
// Only has Provider (no Model, no Tools) -> Provider Only
322+
if (hasProvider && !hasModel && !hasTools) {
323+
return 'provider-only'
324+
}
325+
326+
// Has Model (with or without Provider, but no Tools) -> Model Only
327+
if (hasModel && !hasTools) {
322328
return 'model-only'
323329
}
324330

331+
// Fallback (should not reach here in normal cases)
325332
return 'provider-only'
326333
}
327334

0 commit comments

Comments
 (0)